Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9c4200827 | ||
|
|
97559873dc | ||
|
|
0683b27534 | ||
|
|
49c42e8096 | ||
|
|
ed39e112a9 | ||
|
|
25edab923a | ||
|
|
b8ae3c4402 | ||
|
|
fb537b1d61 | ||
|
|
90439022e3 | ||
|
|
b4c8738b8a | ||
|
|
e193bf9b13 | ||
|
|
a70d8fc2c7 | ||
|
|
d9f69d7917 | ||
|
|
28ac23c2f6 | ||
|
|
b06c49f213 | ||
|
|
8553efabc1 | ||
|
|
81a08ffd5b | ||
|
|
296dae96a5 | ||
|
|
337f529afd | ||
|
|
4360f2830a | ||
|
|
894cc938a5 | ||
|
|
01801ba950 | ||
|
|
5b592575a4 | ||
|
|
7cce03d092 | ||
|
|
ff92a6d26c | ||
|
|
4fa5faa2bf | ||
|
|
98850a7c65 | ||
|
|
15bac15c33 | ||
|
|
b2ff3efb3b | ||
|
|
b9ce3f92a4 | ||
|
|
f65b151bc3 | ||
|
|
703c93db25 | ||
|
|
d0353b137b | ||
|
|
a6c4c1d39c | ||
|
|
7aa4fe142a | ||
|
|
9f8337574a | ||
|
|
82eadebe3b | ||
|
|
9701747214 | ||
|
|
6ff25ed426 | ||
|
|
ecc41bfe31 | ||
|
|
94055d2c92 | ||
|
|
5b50400f28 | ||
|
|
688a4bcf52 | ||
|
|
4bcbb08650 | ||
|
|
1a044145ab | ||
|
|
59299cdbed | ||
|
|
4f74090818 | ||
|
|
70163d22f0 | ||
|
|
b4445fc4d8 | ||
|
|
4022ccde84 | ||
|
|
8d370fd1bb | ||
|
|
5100e8bf3b | ||
|
|
899b04e49a | ||
|
|
07053a6b9a | ||
|
|
58db1d49ac | ||
|
|
a2d678ee19 | ||
|
|
da62e70c02 | ||
|
|
f19d30f58a | ||
|
|
a8202adbec | ||
|
|
5dc58ffa42 | ||
|
|
f4f700ecda | ||
|
|
94178775d5 | ||
|
|
1d3f731483 | ||
|
|
6926d5b065 | ||
|
|
46e9761cae | ||
|
|
fa828f5dea | ||
|
|
3a86903827 | ||
|
|
4feef5700d | ||
|
|
41e2b5af90 | ||
|
|
27f071ba6e | ||
|
|
9020251ed5 | ||
|
|
84822e699e | ||
|
|
3d57efba6c |
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
10
.github/ISSUE_TEMPLATE/custom.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Custom issue template
|
||||
about: Describe this issue template's purpose here.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
75
.htaccess
Normal file
@@ -0,0 +1,75 @@
|
||||
# -----------------------------
|
||||
# 1) Prevent directory listings
|
||||
# -----------------------------
|
||||
Options -Indexes
|
||||
|
||||
# -----------------------------
|
||||
# Default index files
|
||||
# -----------------------------
|
||||
DirectoryIndex index.html
|
||||
|
||||
# -----------------------------
|
||||
# Deny access to hidden files
|
||||
# -----------------------------
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# -----------------------------
|
||||
# Enforce HTTPS (optional)
|
||||
# -----------------------------
|
||||
RewriteEngine On
|
||||
#RewriteCond %{HTTPS} off
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Allow requests from a specific origin
|
||||
#Header set Access-Control-Allow-Origin "https://demo.filerise.net"
|
||||
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
|
||||
Header set Access-Control-Allow-Credentials "true"
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Prevent clickjacking
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
# Block XSS
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
# No MIME sniffing
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# HTML: always revalidate
|
||||
<FilesMatch "\.(html|htm)$">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
# JS/CSS: short‑term cache, revalidate regularly
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Additional Security Headers
|
||||
# -----------------------------
|
||||
<IfModule mod_headers.c>
|
||||
# Enforce HTTPS for a year with subdomains and preload option.
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# Set a Referrer Policy.
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Permissions Policy: disable features you don't need.
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
# IE-specific header to prevent downloads from opening in IE.
|
||||
Header always set X-Download-Options "noopen"
|
||||
# Expect-CT header for Certificate Transparency (optional).
|
||||
Header always set Expect-CT "max-age=86400, enforce"
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Disable TRACE method
|
||||
# -----------------------------
|
||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||
RewriteRule .* - [F]
|
||||
819
CHANGELOG.md
Normal file
@@ -0,0 +1,819 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 4/13/2025 v1.1.3
|
||||
|
||||
- Decreased header height some more and clickable logo.
|
||||
- authModals.js fully updated with i18n.js keys.
|
||||
- main.js added Dark & Light mode i18n.js keys.
|
||||
- New Admin section Header Settings to change Header Title.
|
||||
- Admin Panel confirm unsaved changes.
|
||||
- Added translations and data attributes for almost all user-facing text
|
||||
- Extend i18n support: Add new translation keys for Download and Share modals
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/12/2025
|
||||
|
||||
- Moved Gallery view toggle button into header.
|
||||
- Removed css entries that are not needed anymore for Gallery View Toggle.
|
||||
- Change search box text when enabling advanced search.
|
||||
- Advanced/Basic search button as material icon on same row as search bar.
|
||||
|
||||
### Advanced Search Implementation
|
||||
|
||||
- **Advanced Search Toggle:**
|
||||
- Added a global toggle (`window.advancedSearchEnabled`) and a UI button to switch between basic and advanced search modes.
|
||||
- The toggle button label changes between "Advanced Search" and "Basic Search" to reflect the active mode.
|
||||
|
||||
- **Fuse.js Integration Updates:**
|
||||
- Modified the `searchFiles()` function to conditionally include the `"content"` key in the Fuse.js keys only when advanced search mode is enabled.
|
||||
- Adjusted Fuse.js options by adding `ignoreLocation: true`, adjusting the `threshold`, and optionally assigning weights (e.g., a lower weight for `name` and a higher weight for `content`) to prioritize matches in file content.
|
||||
|
||||
- **Backend (PHP) Enhancements:**
|
||||
- Updated **getFileList.php** to read the content of text-based files (e.g., `.txt`, `.html`, `.md`, etc.) using `file_get_contents()`.
|
||||
- Added a `"content"` property to the JSON response for eligible files to allow for full-text search in advanced mode.
|
||||
|
||||
### Fuse.js Integration for Indexed Real-Time Searching**
|
||||
|
||||
- **Added Fuse.js Library:** Included Fuse.js via a CDN `<script>` tag to leverage its client‑side fuzzy search capabilities.
|
||||
- **Created searchFiles Helper Function:** Introduced a new function that uses Fuse.js to build an index and perform fuzzy searches over file properties (file name, uploader, and nested tag names).
|
||||
- **Transformed JSON Object to Array:** Updated the loadFileList() function to convert the returned file data into an array (if it isn’t already) and assign file names from JSON keys.
|
||||
- **Updated Rendering Functions:** Modified both renderFileTable() and renderGalleryView() to use the searchFiles() helper instead of a simple in‑array .filter(). This ensures that every search—real‑time by user input—is powered by Fuse.js’s indexed search.
|
||||
- **Enhanced Search Configuration:** Configured Fuse.js to search across multiple keys (file name, uploader, and tags) so that users can find files based on any of these properties.
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/11/2025
|
||||
|
||||
- Fixed fileDragDrop issue from previous update.
|
||||
- Fixed User Panel height changing unexpectedly on mouse over.
|
||||
- Improved JS file comments for better documentation.
|
||||
- Fixed userPermissions not updating after initial setting.
|
||||
- Disabled folder and file sharing for readOnly users.
|
||||
- Moved change password close button to the top right of the modal.
|
||||
- Updated upload regex pattern to be Unicode‑enabled and added additional security measures. [(#19)](https://github.com/error311/FileRise/issues/19)
|
||||
- Updated filename, folder, and username regex acceptance patterns.
|
||||
- Updated robthree/twofactorauth to v3 and endroid/qr-code to v5
|
||||
- Updated TOTP integration (namespace, enum, QR provider) accordingly
|
||||
- Updated docker image from 22.04 to 24.04 <https://github.com/error311/filerise-docker>
|
||||
- Ensure consistent session behavior
|
||||
- Fix totp_setup.php to use header-based CSRF token verification
|
||||
|
||||
---
|
||||
|
||||
## Shift Key Multi‑Selection Changes 4/10/2025 v1.1.1
|
||||
|
||||
- **Implemented Range Selection:**
|
||||
- Modified the `toggleRowSelection` function so that when the Shift key is held down, all rows between the last clicked (anchor) row (stored as `window.lastSelectedFileRow`) and the currently clicked row are selected.
|
||||
- **Modifier Handling:**
|
||||
- Regular clicks (or Ctrl/Cmd clicks) simply toggle the clicked row without clearing other selections.
|
||||
- **Prevented Default Browser Behavior:**
|
||||
- Added `event.preventDefault()` in the Shift‑click branch to avoid unwanted text selection.
|
||||
- **Maintaining the Anchor:**
|
||||
- The last clicked row is stored for future range selections.
|
||||
|
||||
## Total Files and File Size Summary
|
||||
|
||||
- **Size Calculation:**
|
||||
- Created `parseSizeToBytes(sizeStr)` to convert file size strings (e.g. `"456.9KB"`, `"1.2 MB"`) into a numerical byte value.
|
||||
- Created `formatSize(totalBytes)` to format a byte value into a human‑readable string (choosing between Bytes, KB, MB, or GB).
|
||||
- Created `buildFolderSummary(filteredFiles)` to:
|
||||
- Sum the sizes of all files (using `parseSizeToBytes`).
|
||||
- Count the total number of files.
|
||||
- **Dynamic Display in `loadFileList`:**
|
||||
- Updated `loadFileList` to update a summary element (with `id="fileSummary"`) inside the `#fileListActions` container when files are present.
|
||||
- When no files are found, the summary element is hidden (setting its `display` to `"none"` or clearing the container).
|
||||
- **Responsive Styling:**
|
||||
- Added CSS media queries to the `#fileSummary` element so that on small screens it is centered and any extra side margins are removed. Dark and light mode supported.
|
||||
|
||||
- **Other changes**
|
||||
|
||||
- `shareFolder.php` updated to display format size.
|
||||
- Fix to prevent the filename text from overflowing its container in the gallery view.
|
||||
- Reduced header height.
|
||||
- Create Folder changed to Material Icon `create_new_folder`
|
||||
|
||||
---
|
||||
|
||||
## Folder Sharing Feature - Changelog 4/9/2025 v1.1.0
|
||||
|
||||
### New Endpoints
|
||||
|
||||
- **createFolderShareLink.php:**
|
||||
- Generates secure, expiring share tokens for folders (with an optional password and allow-upload flag).
|
||||
- Stores folder share records separately from file shares in `share_folder_links.json`.
|
||||
- Builds share links that point to **shareFolder.php**, using a proper BASE_URL or the server’s IP when a default placeholder is detected.
|
||||
|
||||
- **shareFolder.php:**
|
||||
- Serves shared folders via GET requests by reading tokens from `share_folder_links.json`.
|
||||
- Validates token expiration and password (if set).
|
||||
- Displays folder contents with pagination (10 items per page) and shows file sizes in megabytes.
|
||||
- Provides navigation links (Prev, Next, and numbered pages) for folder listings.
|
||||
- Includes an upload form (if allowed) that redirects back to the same share page after upload.
|
||||
|
||||
- **downloadSharedFile.php:**
|
||||
- A dedicated, secure download endpoint for shared files.
|
||||
- Validates the share token and ensures the requested file is inside the shared folder.
|
||||
- Serves files using proper MIME types and Content-Disposition headers (inline for images, attachment for others).
|
||||
|
||||
- **uploadToSharedFolder.php:**
|
||||
- Handles file uploads for public folder shares.
|
||||
- Enforces file size limits and file type whitelists.
|
||||
- Generates unique filenames (with a unique prefix) to prevent collisions.
|
||||
- Updates metadata for the uploaded file (upload date and sets uploader as "Outside Share").
|
||||
- Redirects back to **shareFolder.php** after a successful upload so the file listing refreshes.
|
||||
|
||||
### New Front-End Module
|
||||
|
||||
- **folderShareModal.js:**
|
||||
- Provides a modal interface for users to generate folder share links.
|
||||
- Includes expiration selection, optional password entry, and an allow-upload checkbox.
|
||||
- Uses the **createFolderShareLink.php** endpoint to generate share links.
|
||||
- Displays the generated share link with a “copy to clipboard” button.
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/8/2025
|
||||
|
||||
**May have missed some stuff or could have bugs. Please report any issue you may encounter.**
|
||||
|
||||
- **i18n Integration:**
|
||||
- Implemented a semi-complete internationalization (i18n) system for all user-facing texts in FileRise.
|
||||
- Created an `i18n.js` module containing a translations object with full keys for English (en), Spanish (es), and French (fr).
|
||||
- Updated JavaScript code to replace hard-coded strings with the `t()` translation function.
|
||||
- Enhanced HTML and modal templates to support dynamic language translations using data attributes (data-i18n-key, data-i18n-placeholder, etc.).
|
||||
|
||||
- **Language Dropdown & Persistence:**
|
||||
- Added a language dropdown to the user panel modal allowing users to select their preferred language.
|
||||
- Persisted the selected language in localStorage, ensuring that the preferred language is automatically applied on page refresh.
|
||||
- Updated main.js to load and set the user’s language preference on DOMContentLoaded by calling `setLocale()` and `applyTranslations()`.
|
||||
|
||||
- **Bug Fixes & Improvements:**
|
||||
- Fixed issues with evaluation of translation function calls in template literals (ensured proper syntax with `${t("key")}`).
|
||||
- Updated the t() function to be more defensive against missing keys.
|
||||
- Provided instructions and code examples to ensure the language change settings are reliably saved and applied across sessions.
|
||||
|
||||
- **ZIP Download Flow**
|
||||
- Progress Modal: In the ZIP download handler (confirmDownloadZip), added code to show a progress modal (with a spinning icon) as soon as the user confirms the download and before the request to create the ZIP begins. Once the blob is received or an error occurs, we hide the progress modal.
|
||||
- Inline Handlers and Global Exposure: Ensured that functions like confirmDownloadZip are attached to the global window object (or called via appropriate inline handlers) so that the inline onclick events in the HTML work without reference errors.
|
||||
|
||||
- **Single File Download Flow**
|
||||
- Modal Popup for Single File: Replaced the direct download link for single files with a modal-driven flow. When the download button is clicked, the openDownloadModal(fileName, folder) function is called. This stores the file details and shows a modal where the user can confirm (or edit) the file name.
|
||||
- Confirm Download Function: When the user clicks the Download button in the modal, the confirmSingleDownload() function is called. This function constructs a URL for download.php (using GET parameters for folder and file), fetches the file as a blob, and triggers a download using a temporary anchor element. A progress modal is also used here to give feedback during the download process.
|
||||
|
||||
- **Zip Extraction**
|
||||
- Reused Zip Download modal to use same progress Modal Popup with Extracting files.... text.
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/7/2025 v1.0.9
|
||||
|
||||
- TOTP one time recovery code added
|
||||
- fix(security): mitigate CodeQL alerts by adding SRI attributes and sanitizing DOM content
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/6/2025 v1.0.8
|
||||
|
||||
**May need to log out and log back in if using remember me**
|
||||
|
||||
Changelog: Modularize fileManager.js
|
||||
|
||||
1. **fileListView.js**
|
||||
• Extracted all table/gallery rendering logic (loadFileList, renderFileTable, renderGalleryView, sortFiles, date parsing, pagination).
|
||||
• Kept global helpers on window (changePage, changeItemsPerPage).
|
||||
• Added explicit re‑binding of context‑menu and drag‑drop handlers after each render.
|
||||
2. **filePreview.js**
|
||||
• Moved “Preview” and “Share” modal code here (previewFile, openShareModal, plus displayFilePreview helper).
|
||||
• Exposed window.previewFile for inline onclick compatibility.
|
||||
3. **fileEditor.js**
|
||||
• Isolated CodeMirror editor logic (editFile, saveFile, sizing, theme toggles).
|
||||
• Exported utility functions (getModeForFile, adjustEditorSize, observeModalResize).
|
||||
4. **fileDragDrop.js**
|
||||
• Encapsulated all drag‑start and folder drag/drop handlers (fileDragStartHandler, folderDragOverHandler, etc.).
|
||||
5. **fileMenu.js** (formerly contextMenu.js)
|
||||
• Centralized right‑click context menu construction and binding (showFileContextMenu, fileListContextMenuHandler, bindFileListContextMenu).
|
||||
• Now calls the correct single vs. multi‑tag modals.
|
||||
6. **fileActions.js**
|
||||
• Consolidated all “Delete”, “Copy”, “Move”, “Download Zip”, “Extract Zip”, “Rename” workflows and their modals.
|
||||
• Exposed initFileActions() to wire up toolbar buttons on page load.
|
||||
7. **fileManager.js** (entry point)
|
||||
• Imports all the above modules.
|
||||
• On DOM ready: calls initFileActions(), attaches folder tree drag/drop, and global key handlers.
|
||||
|
||||
Changelog: OIDC, Basic Auth & TOTP Integration
|
||||
|
||||
1. **auth.php (OIDC)**
|
||||
• Detects callback via `?code` or `?oidc=callback`.
|
||||
• Checks for a TOTP secret after OIDC auth, stores pending login in session, redirects with `?totp_required=1`.
|
||||
• Finalizes session only after successful TOTP verification.
|
||||
|
||||
2. **login_basic.php (Basic Auth)**
|
||||
• After password verification, checks for TOTP secret.
|
||||
• Stores pending login & secret in session, redirects to TOTP modal.
|
||||
• Completes session setup only after TOTP verification.
|
||||
|
||||
3. **authModals.js & auth.js**
|
||||
• Detect `?totp_required=1` and open the TOTP modal.
|
||||
• Override `showToast` to suppress “Please log in…” during TOTP.
|
||||
• Wrap `openTOTPLoginModal` to disable Basic/OIDC buttons (but keep form-login visible).
|
||||
• On invalid TOTP code, keep modal open, clear input, and refocus for retry.
|
||||
|
||||
4. **totp_verify.php**
|
||||
• Consolidates login and setup TOTP flows in one endpoint.
|
||||
• Enforces CSRF token and authentication guard.
|
||||
• Verifies TOTP, regenerates session on success, and clears pending state.
|
||||
• Production‑hardened: secure cookies, CSP header, rate‑limiting (5 attempts), standardized JSON responses, and robust error handling.
|
||||
|
||||
---
|
||||
|
||||
## changes 4/4/2025
|
||||
|
||||
- fix(`download.php`): mitigate path traversal vulnerability by validating folder and file inputs
|
||||
- Fixed OIDC login button DOM.
|
||||
- Fixed userPermissions calling username before declared.
|
||||
- Fixed config.php loadUserPermissions issue.
|
||||
- Chain Initialization After CSRF Token Is Loaded
|
||||
- loadCsrfTokenWithRetry
|
||||
|
||||
---
|
||||
|
||||
## changes 4/3/2025
|
||||
|
||||
Change Log for dragAndDrop.js Enhancements
|
||||
|
||||
- **Header Drop Zone Integration:**
|
||||
- Added a new header drop zone (`#headerDropArea`) to support dragging cards (Upload and Folder Management) into the header.
|
||||
- Created functionality to display a compact Material icon in the header when a card is dropped there.
|
||||
|
||||
- **Modal Popup for Header Cards:**
|
||||
- Implemented a modal overlay that displays the full card when the user hovers or clicks the header icon.
|
||||
- Added toggle functionality so that the modal can be locked open or auto-hide based on mouse interactions.
|
||||
|
||||
- **State Preservation via Hidden Container:**
|
||||
- Introduced a hidden container (`#hiddenCardsContainer`) to preserve the original state of the Upload and Folder Management cards.
|
||||
- Modified logic so that instead of removing these cards from the DOM when dropped into the header, they are moved to the hidden container.
|
||||
- Updated modal show/hide functions to move the card from the hidden container into the modal (and back), ensuring interactive elements (e.g., folder tree, file selection) remain fully initialized and retain their state across page refreshes.
|
||||
|
||||
- **Local Storage Integration for Header Order:**
|
||||
- Added `saveHeaderOrder()` and `loadHeaderOrder()` functions to persist the header drop zone order.
|
||||
- Integrated header order saving/updating with drag-and-drop events so that header placements are maintained after refresh.
|
||||
|
||||
- **General Drag & Drop Enhancements:**
|
||||
- Maintained smooth drag-and-drop animations and reflow for all drop zones (sidebar, top, and header).
|
||||
- Ensured existing functionalities (like file uploads and folder tree interactions) work seamlessly alongside the new header drop zone.
|
||||
|
||||
## Brief Description
|
||||
|
||||
The enhancements extend the existing drag-and-drop functionality by adding a header drop zone where cards are represented by a compact Material icon. To preserve interactive state (such as the folder tree’s current folder or file input functionality) across page refreshes, the original cards are never fully removed from the DOM. Instead, they are moved into a hidden container, and when a user interacts with the header icon, the card is temporarily transferred into a modal overlay for full interaction. When the modal is closed, the card is returned to the hidden container, ensuring that its state remains intact. Additionally, header order is saved to local storage so that user-customized layouts persist across sessions.
|
||||
|
||||
---
|
||||
|
||||
## changes 4/2/2025
|
||||
|
||||
- **Admin Panel - User Permissions**
|
||||
- folderOnly - User gets their own root folder.
|
||||
- readOnly - User can't delete, rename, move, copy and other endpoints are blocked.
|
||||
- disableUpload - User can't upload any files.
|
||||
- Encrypted json 'userPermissions.json'
|
||||
- Created 'updateUserPermissions.php' & 'getUserPermissions.php'
|
||||
|
||||
- **TOTP Confirmation**
|
||||
- Must confirm code before it will enable TOTP.
|
||||
- 'totp_verify.php' & 'totp_disable.php' were created
|
||||
|
||||
- **Basic Auth & OIDC fixes**
|
||||
- Fixed session issues
|
||||
- Improvements for both Basic Auth & OIDC
|
||||
|
||||
- Path Normalization
|
||||
- Folder Rendering Adjustments
|
||||
- Folder Creation Logic adjusted
|
||||
- User Panel added username
|
||||
- Admin Panel added version number
|
||||
- Metadata Adjustments
|
||||
- Toast moved to bottom right
|
||||
- Help function 'loadUserPermissions()'
|
||||
- 'auth.js' split into 'authModals.js'
|
||||
- Empty 'createdTags.json' added
|
||||
- Enable MKV video playback if supported
|
||||
- Custom toast opacity increased
|
||||
- Fixed fileDragStartHandler to work with tagFiles
|
||||
- And more
|
||||
|
||||
---
|
||||
|
||||
## changes 3/31/2025
|
||||
|
||||
- **Chunk merging logic updated to attempt to clear any resumable issues**
|
||||
|
||||
- **Implemented Video Progress Saving and Resuming**
|
||||
|
||||
- **Context Menu Tagging:**
|
||||
- "Tag File" option for single files; "Tag Selected" for multiple files.
|
||||
- **Tagging Modals:**
|
||||
- Separate modals for single‑ and multi‑file tagging with custom dropdowns.
|
||||
- **Global Tag Store:**
|
||||
- Reusable tags persisted via `createdTags.json`; dropdown shows tag color and remove icon.
|
||||
- **Unified Search:**
|
||||
- Single search box filters files by name or associated tag(s).
|
||||
|
||||
- **saveFileTag.php:**
|
||||
- Saves file-specific tags and updates global tags (supports removal).
|
||||
- **getFileList.php:**
|
||||
- Returns tag data for each file and the global tag list.
|
||||
|
||||
- Added `openMultiTagModal()` for batch tagging.
|
||||
- Custom dropdowns with colored tag previews and removal buttons.
|
||||
- Filtering logic updated in table and gallery views to combine file name and tag searches.
|
||||
|
||||
## changes 3/30/2025
|
||||
|
||||
- **New Feature:** Generates a QR code for TOTP setup using the Endroid QR Code library.
|
||||
- **TOTP Secret Management:**
|
||||
- Retrieves the current user's TOTP secret from the users file.
|
||||
- If no secret exists, generates a new one using RobThree\Auth\TwoFactorAuth and stores it (encrypted).
|
||||
- **Global OTPAuth URL Integration:**
|
||||
- Checks for a global OTPAuth URL in the admin configuration.
|
||||
- If provided, replaces the `{label}` and `{secret}` placeholders in the URL template; otherwise, falls back to a default otpauth URL.
|
||||
- **Security:**
|
||||
- Enforces session authentication.
|
||||
- Verifies the CSRF token passed via GET parameters.
|
||||
|
||||
- **New Feature:** Handles AJAX requests to update the user’s TOTP settings from the User Panel.
|
||||
- **TOTP Enable/Disable Handling:**
|
||||
- If TOTP is disabled, clears the user's TOTP secret from the users file.
|
||||
- If TOTP remains enabled, leaves the stored secret intact.
|
||||
- **Security:**
|
||||
- Validates user authentication and CSRF token before processing the update.
|
||||
- **Response:**
|
||||
- Returns a JSON response indicating whether TOTP has been enabled or disabled successfully.
|
||||
|
||||
- **New TOTP Settings Section:**
|
||||
- A "TOTP Settings" fieldset has been added to the User Panel modal.
|
||||
- **Automatic TOTP Setup Trigger:**
|
||||
- When the "Enable TOTP" checkbox is checked, it automatically triggers the TOTP Setup Modal to display the QR code.
|
||||
- **State Management**
|
||||
- **UI Improvements:**
|
||||
- All modals (User Panel, TOTP Setup, and TOTP Login) now support dark mode styling.
|
||||
|
||||
- **Error Handling & Security:**
|
||||
- Enhanced error handling across all new TOTP-related endpoints.
|
||||
- Added extra CSRF and authentication checks to improve security.
|
||||
- **User Experience:**
|
||||
- Streamlined the onboarding process for TOTP by integrating automatic modal triggers and real-time configuration updates.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/29/2025
|
||||
|
||||
**Frontend (JavaScript)**
|
||||
|
||||
**File:** `auth.js`
|
||||
|
||||
- **Added OIDC Login Flow**
|
||||
- Created a dedicated OIDC login button (`oidcLoginBtn`).
|
||||
- Redirects users to OIDC authentication via `auth.php?oidc`.
|
||||
|
||||
- **Admin Panel Button**
|
||||
- Added an “Admin Panel” button (`adminPanelBtn`) with a Material icon (`admin_panel_settings`).
|
||||
- Inserted Admin Panel button directly after the Restore button in the header.
|
||||
|
||||
- **Admin Panel Modal**
|
||||
- Built a fully-featured admin panel modal with fields to edit:
|
||||
- OIDC Provider URL
|
||||
- Client ID
|
||||
- Client Secret
|
||||
- Redirect URI
|
||||
- Options to disable Form Login, Basic Auth, or OIDC login methods individually.
|
||||
- Integrated real-time constraint enforcement to ensure at least one authentication method is always enabled.
|
||||
- Saved admin preferences into local storage and backend (via `updateConfig.php`).
|
||||
|
||||
- **Dynamic UI Updates**
|
||||
- Added functions (`updateLoginOptionsUI`, `updateLoginOptionsUIFromStorage`) to dynamically show/hide login elements based on admin preferences.
|
||||
|
||||
⸻
|
||||
|
||||
**Backend (PHP)**
|
||||
|
||||
**File:** `auth.php`
|
||||
|
||||
- **OIDC Authentication**
|
||||
- Integrated Jumbojett’s OpenID Connect client to handle OIDC flows.
|
||||
- Reads OIDC configuration from an encrypted JSON file (`adminConfig.json`).
|
||||
- Redirects users to OIDC provider and handles callbacks properly, authenticating users and initiating PHP sessions.
|
||||
|
||||
- **Security Enhancements**
|
||||
- Implemented robust error handling for authentication failures.
|
||||
- Session regeneration after successful login to mitigate session fixation risks.
|
||||
|
||||
**Configuration Handling**
|
||||
|
||||
**File:** `getConfig.php`
|
||||
|
||||
- **Secure Configuration Retrieval**
|
||||
- Retrieves encrypted OIDC configuration from disk.
|
||||
- Decrypts and sends JSON configuration securely to the frontend.
|
||||
- Defaults provided if configuration does not exist.
|
||||
|
||||
**File:** `updateConfig.php`
|
||||
|
||||
- **Secure Configuration Updates**
|
||||
- Strictly checks for authenticated admin sessions and validates CSRF tokens.
|
||||
- Validates and sanitizes user input thoroughly (OIDC URL, client ID, secret, redirect URI).
|
||||
- Updates encrypted configuration file securely, ensuring atomic writes (`LOCK_EX`).
|
||||
|
||||
- **Consistent Styling**
|
||||
- Modal dynamically adjusts styling based on dark/light modes.
|
||||
- Improved accessibility with clear icons, visual hierarchy, and structured form fields.
|
||||
|
||||
- **Enhanced Feedback**
|
||||
- Toast notifications clearly communicate success/error messages for user/admin actions.
|
||||
|
||||
⸻
|
||||
|
||||
**Security and Best Practices**
|
||||
|
||||
- OIDC credentials are securely stored in an encrypted JSON configuration file.
|
||||
- Implemented proper sanitization and validation of input data.
|
||||
- Protected sensitive admin routes (`updateConfig.php`) with CSRF validation and strict access control.
|
||||
|
||||
⸻
|
||||
|
||||
**Possible Improvements**
|
||||
|
||||
- **OIDC Logout Support:** Add explicit logout from OIDC providers.
|
||||
- **OIDC Discovery Endpoint:** Automatically fetch provider details from `.well-known/openid-configuration`.
|
||||
- **Advanced User Mapping:** Allow administrators to map OIDC claims to internal user roles dynamically.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/27/2025
|
||||
|
||||
- Basic Auth added for login.
|
||||
- Audio files supported for playback mp3|wav|m4a|ogg|flac|aac|wma|opus
|
||||
|
||||
---
|
||||
|
||||
## changes 3/26/2025
|
||||
|
||||
- New name change FileRise - Elevate your file management.
|
||||
- Animated logo that rises up once for 3 seconds and falls back down
|
||||
- New Side Bar and Top Bar drop areas
|
||||
- Drag and Drop Upload & Folder Management cards
|
||||
- Vertical slide up effect when dropping cards
|
||||
- Fixed double root folders when only root folder exist
|
||||
- Adjusted side bar drop zone
|
||||
|
||||
---
|
||||
|
||||
## changes 3/25/2025
|
||||
|
||||
- **Context Menu Enhancements:**
|
||||
- **Right‑Click Context Menu:**
|
||||
- Added context menu support for file list rows so that right‑clicking shows a custom menu.
|
||||
- When multiple files are selected, options like “Delete Selected”, “Copy Selected”, “Move Selected”, “Download Zip” are shown.
|
||||
- When a file with a “.zip” extension is among the selections, an “Extract Zip” option is added.
|
||||
- **Single File Options:**
|
||||
- For a single selected file, additional items (“Preview”, “Edit”, and “Rename”) are appended.
|
||||
- The “Edit” option appears only if `canEditFile(file.name)` returns true.
|
||||
- **Keyboard Shortcuts:**
|
||||
- **Delete Key Shortcut:**
|
||||
- Added a global keydown listener to detect the Delete (or Backspace on Mac) key.
|
||||
- When pressed (and if no input/textarea is focused) with files selected, it triggers `handleDeleteSelected()` to open the delete confirmation modal.
|
||||
- **Modals & Enter-Key Handling:**
|
||||
- **attachEnterKeyListener Update:**
|
||||
- Modified the function to use the “keydown” event (instead of “keypress”) for better reliability.
|
||||
- Ensured the modal is made focusable (by setting a `tabindex="-1"`) and focused immediately after being displayed.
|
||||
- This update was applied to modals for rename, download zip, and delete operations.
|
||||
- **Delete Modal Specific:**
|
||||
- It was necessary to call `attachEnterKeyListener` for the delete modal after setting its display to “block” to ensure it captures the Enter key.
|
||||
- **File Editing Adjustments:**
|
||||
- **Content-Length Check:**
|
||||
- Modified the `editFile` function so that it only blocks files when the Content-Length header is non‑null and greater than 10 MB.
|
||||
- This change allows editing of 0 KB files (or files with Content-Length “0”) without triggering the “File too large” error.
|
||||
|
||||
- **Context Menu for Folder Manager:**
|
||||
- Provided a separate implementation for a custom context menu for folder manager elements.
|
||||
- Bound the context menu to both folder tree nodes (`.folder-option`) and breadcrumb links (`.breadcrumb-link`) so that right‑clicking on either triggers a custom menu.
|
||||
- The custom menu for folders includes actions for “Create Folder”, “Rename Folder”, and “Delete Folder.”
|
||||
- Added guidance to ensure that breadcrumb HTML elements contain the appropriate class and `data-folder` attribute.
|
||||
- **Keyboard Shortcut for Folder Deletion (Suggestion):**
|
||||
- Suggested adding a global keydown listener in `folderManager.js` to trigger folder deletion (via `openDeleteFolderModal()`) when Delete/Backspace is pressed and a folder other than “root” is selected.
|
||||
|
||||
- **Event Listener Timing:**
|
||||
- Ensured that context menu and key event listeners are attached after the corresponding DOM elements are rendered.
|
||||
- Added explicit focus calls (and `tabindex` attributes) for modals to capture keyboard events.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/24/2025
|
||||
|
||||
### config.php
|
||||
|
||||
- **Encryption Functions Added:**
|
||||
- Introduced `encryptData()` and `decryptData()` functions using AES‑256‑CBC to encrypt and decrypt persistent tokens.
|
||||
- **Encryption Key Handling:**
|
||||
- Added code to load the encryption key from an environment variable (`PERSISTENT_TOKENS_KEY`) with a fallback default.
|
||||
- **Persistent Token Auto-Login:**
|
||||
- Modified the auto-login logic to check for a `remember_me_token` cookie.
|
||||
- If the persistent tokens file exists, it now reads and decrypts its content before decoding JSON.
|
||||
- If a token is expired, the code removes the token, re-encrypts the updated array, writes it back to disk, and clears the cookie.
|
||||
- **Cookie and Session Settings:**
|
||||
- No major changes aside from integrating the encryption functionality into the token handling.
|
||||
|
||||
### auth.php
|
||||
|
||||
- **Login Process and “Remember Me” Functionality:**
|
||||
- When “Remember me” is checked, generates a secure random token.
|
||||
- Loads the persistent tokens file (if it exists), decrypts its content, and decodes the JSON.
|
||||
- Inserts the new token (with associated username and expiry) into the persistent tokens array.
|
||||
- Encrypts the updated tokens array and writes it back to the file.
|
||||
- Sets the `remember_me_token` cookie using the `$secure` flag and expiry.
|
||||
- **Authentication & Brute Force Protection:**
|
||||
- The authentication logic and brute-force protection remain largely unchanged.
|
||||
|
||||
### logout.php
|
||||
|
||||
- **Persistent Token Removal:**
|
||||
- If a `remember_me_token` cookie exists, the script loads the persistent tokens file, decrypts its content, removes the token if present, re-encrypts the array, and writes it back.
|
||||
- **Cookie Clearance and Session Destruction:**
|
||||
- Clears the `remember_me_token` cookie.
|
||||
- Destroys session data as before.
|
||||
|
||||
### networkUtils.js
|
||||
|
||||
- **Fetch Wrapper Enhancements:**
|
||||
- Modified `sendRequest()` to clone the response before attempting to parse JSON.
|
||||
- If JSON parsing fails (e.g., because of unexpected response content), the cloned response is used to read the text, preventing the “Body is disturbed or locked” error.
|
||||
- **Error Handling Improvements:**
|
||||
- Improved error handling by ensuring the response body is read only once.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/23/2025 v1.0.1
|
||||
|
||||
- **Resumable File Upload Integration and Folder Support**
|
||||
- **Legacy Drag-and-Drop Folder Uploads:**
|
||||
- Supports both file and folder uploads via drag-and-drop.
|
||||
- Recursively traverses dropped folders to extract files.
|
||||
- Uses original XHR-based upload code for folder uploads so that files are placed in the correct folder (i.e. based on the current folder in the app’s folder tree).
|
||||
- **Resumable.js for File Picker Uploads:**
|
||||
- Integrates Resumable.js for file uploads via the file picker.
|
||||
- Provides pause, resume, and retry functionality:
|
||||
- Pause/Resume: A pause/resume button is added for each file selected via the file picker. When the user clicks pause, the file upload pauses and the button switches to a “play” icon. When the user clicks it again, the system triggers a resume sequence (calling the upload function twice to ensure proper restart).
|
||||
- Retry: If a file upload encounters an error, the pause/resume button changes to a “replay” icon, allowing the user to retry the upload.
|
||||
- During upload, the UI displays the progress percentage along with the calculated speed (bytes/KB/MB per second).
|
||||
- Files are previewed using material icons for non-image files and actual image previews for image files (using a helper function that creates an object URL for image files).
|
||||
- **Temporary Chunk Folder Removal:**
|
||||
- When a user cancels an upload via the remove button (X), a POST request is sent to a PHP endpoint (removeChunks.php) that:
|
||||
- Validates the CSRF token.
|
||||
- Recursively deletes the temporary folder (which stores file chunks) from the uploads directory.
|
||||
- **Additional Details:**
|
||||
- The file list UI remains visible (instead of auto-disappearing after 5 seconds) if there are any files still present or errors, ensuring that users can retry failed uploads.
|
||||
- The system uses a chunk size of 3MB and supports multiple simultaneous uploads.
|
||||
- All endpoints include CSRF protection and input validation to ensure secure operations.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/22/2025
|
||||
|
||||
- Change Password added and visibile to all users.
|
||||
- Brute force protection added and a log file for fail2ban created
|
||||
- Fix add user and setup user issue
|
||||
- Added folder breadcrumb with drag and drop support
|
||||
|
||||
---
|
||||
|
||||
## changes 3/21/2025 v1.0.0
|
||||
|
||||
- **Trash Feature Implementation**
|
||||
- Added functionality to move deleted files to a Trash folder.
|
||||
- Implemented trash metadata storage (trash.json) capturing original folder, file name, trashed timestamp, uploader, and deletedBy.
|
||||
- Developed restore feature allowing admins to restore individual or all files from Trash.
|
||||
- Developed delete feature allowing permanent deletion (Delete Selected and Delete All) from Trash.
|
||||
- Implemented auto-purge of trash items older than 3 days.
|
||||
- Updated trash modal design for better user experience.
|
||||
- Incorporated material icons with tooltips in restore/delete buttons.
|
||||
- Improved responsiveness of the trash modal with a centered layout and updated CSS.
|
||||
- Fixed issues where trashed files with missing metadata were not restored properly.
|
||||
- Resolved problems with the auto-purge mechanism when trash.json was empty or contained unexpected data.
|
||||
- Adjusted admin button logic to correctly display the restore button for administrators.
|
||||
- Improved error handling on restore and delete actions to provide more informative messages to users.
|
||||
- **Other changes**
|
||||
- CSS adjusted (this needs to be refactored)
|
||||
- Fixed setup mode CSRF issue in addUser.php
|
||||
- Adjusted modals buttons in index.html & folderManager.js
|
||||
- Changed upload.php safe pattern
|
||||
- Hide trash folder
|
||||
- Reworked auth.js
|
||||
|
||||
---
|
||||
|
||||
## changes 3/20/2025
|
||||
|
||||
- **Drag & Drop Feature**
|
||||
- For a single file: shows a file icon alongside the file name.
|
||||
- For multiple files: shows a file icon and a count of files.
|
||||
- Styling Adjustments:
|
||||
- Modified drag image styling (using inline-flex, auto width, and appropriate padding) so that the drag image only sizes to its content and does not extend off the screen.
|
||||
- Revised the folder drop handler so that it reads the array of file names from the drag data and sends that array (instead of a single file name) to the server (moveFiles.php) for processing.
|
||||
- Attached dragover, dragleave, and drop event listeners to folder tree nodes (the elements with the class folder-option) to enable a drop target.
|
||||
- Added a global dragover event listener (in main.js) that auto-scrolls the page when the mouse is near the top or bottom of the viewport during a drag operation. This ensures you can reach the folder tree even if you’re far down the file list.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/19/2025
|
||||
|
||||
## Session & Security Enhancements
|
||||
|
||||
- **Secure Session Cookies:**
|
||||
- Configured session cookies with a 2-hour lifetime, HTTPOnly, and SameSite settings.
|
||||
- Regenerating the session ID upon login to mitigate session fixation.
|
||||
- **CSRF Protection:**
|
||||
- Ensured the CSRF token is generated in `config.php` and returned via a `token.php` endpoint.
|
||||
- Updated front-end code (e.g. in `main.js`) to fetch the CSRF token and update meta tags.
|
||||
- **Session Expiration Handling:**
|
||||
- Updated the `loadFileList` and other functions to check for HTTP 401 responses and trigger a logout or redirect if the session has expired.
|
||||
|
||||
## File Management Improvements
|
||||
|
||||
### Unique Naming to Prevent Overwrites
|
||||
|
||||
- **Copy & Move Operations:**
|
||||
- Added a helper function `getUniqueFileName()` to both `copyFiles.php` and `moveFiles.php` that checks for duplicates and appends a counter (e.g., “ (1)”) until a unique filename is determined.
|
||||
- Updated metadata handling so that when a file is copied/moved and renamed, the corresponding metadata JSON (per-folder) is updated using the new unique filename.
|
||||
- **Rename Functionality:**
|
||||
- Updated `renameFile.php` to:
|
||||
- Allow filenames with parentheses by updating the regex.
|
||||
- Check if a file with the new name already exists.
|
||||
- Generate a unique name using similar logic if needed.
|
||||
- Update folder-specific metadata accordingly.
|
||||
|
||||
### Metadata Management
|
||||
|
||||
- **Per-Folder Metadata Files:**
|
||||
- Changed metadata storage so that each folder uses its own metadata file (e.g., `root_metadata.json` for the root folder and `FolderName_metadata.json` for subfolders).
|
||||
- Updated metadata file path generation functions to replace slashes, backslashes, and spaces with dashes.
|
||||
|
||||
## Gallery / Grid View Enhancements
|
||||
|
||||
- **Gallery (Grid) View:**
|
||||
- Added a toggle option to switch between a traditional table view and a gallery view.
|
||||
- The gallery view arranges image thumbnails in a grid layout with configurable column options (e.g., 3, 4, or 5 columns).
|
||||
- Under each thumbnail, action buttons (Download, Edit, Rename, Share) are displayed for quick access.
|
||||
- **Preview Modal Enhancements:**
|
||||
- Updated the image preview modal to include navigation buttons (prev/next) for browsing through images.
|
||||
- Improved scaling and styling of preview modals for a better user experience.
|
||||
|
||||
## Share Link Functionality
|
||||
|
||||
- **Share Link Generation (createShareLink.php):**
|
||||
- Generate shareable links for files with:
|
||||
- A secure token.
|
||||
- Configurable expiration times (including options for 30, 60, 120, 180, 240 minutes, and a 1-day option).
|
||||
- Optional password protection (passwords are hashed).
|
||||
- Store share links in a JSON file (`share_links.json`) with details (folder, file, expiration timestamp, hashed password).
|
||||
- **Share Endpoint (share.php):**
|
||||
- Validate tokens, expiration, and passwords.
|
||||
- Serve files inline for images or force download for other file types.
|
||||
- Share URL is configurable via environment variables or auto-detected from the server.
|
||||
- **Front-End Configuration:**
|
||||
- Created a `token.php` endpoint that returns CSRF token and SHARE_URL.
|
||||
- Updated the front-end (in `main.js`) to fetch configuration data and update meta tags for CSRF and share URL, allowing index.html to remain static.
|
||||
|
||||
## Apache & .htaccess / Server Security
|
||||
|
||||
- **Disable Directory Listing:**
|
||||
- Recommended adding an .htaccess file (e.g., in `uploads/`) with `Options -Indexes` to disable directory indexing.
|
||||
- **Restrict Direct File Access:**
|
||||
- Protected sensitive files (e.g., users.txt) via .htaccess.
|
||||
- Filtered out hidden files (files beginning with a dot) from the file list in `getFileList.php`.
|
||||
- **Proxy Download:**
|
||||
- A proxy download mechanism has been implemented (via endpoints like `download.php` and `downloadZip.php`) so that every file download request goes through a PHP script. This script validates the session and CSRF token before streaming the file, ensuring that even if a file URL is guessed, only authenticated users can access it.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/18/2025
|
||||
|
||||
- **CSRF Protection:** All state-changing endpoints (such as those for folder and file operations) include CSRF token validation to ensure that only legitimate requests from authenticated users are processed.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/17/2025
|
||||
|
||||
- refactoring/reorganize domUtils, fileManager.js & folerManager.js
|
||||
|
||||
---
|
||||
|
||||
## changes 3/15/2025
|
||||
|
||||
- Preview video, images or PDFs added
|
||||
- Different material icons for each
|
||||
- Custom css to adjust centering
|
||||
- Persistent folder tree view
|
||||
- Fixed folder tree alignment
|
||||
- Persistent last opened folder
|
||||
|
||||
---
|
||||
|
||||
## changes 3/14/2025
|
||||
|
||||
- Style adjustments
|
||||
- Folder/subfolder upload support
|
||||
- Persistent UI elements Items Per Page & Dark/Light modes.
|
||||
- File upload scrollbar list
|
||||
- Remove files from upload list
|
||||
|
||||
---
|
||||
|
||||
## changes 3/11/2025
|
||||
|
||||
- CSS Refactoring
|
||||
- Dark / Light Modes added which automatically adapts to the operating system’s theme preference by default, with a manual toggle option.
|
||||
- JS inlines moved to CSS
|
||||
|
||||
---
|
||||
|
||||
## changes 3/10/2025
|
||||
|
||||
- File Editing Enhancements:
|
||||
- Integrated CodeMirror into the file editor modal for syntax highlighting, line numbers, and adjustable font size.
|
||||
- Added zoom in/out controls (“A-” / “A+”) in the editor modal to let users adjust the text size and number of visible lines.
|
||||
- Updated the save function to retrieve edited content from the CodeMirror instance (using editor.getValue()) instead of the underlying textarea.
|
||||
- Image Preview Improvements:
|
||||
- Added a new “Preview” button (with a Material icon) in the Actions column for image files.
|
||||
- Implemented an image preview modal that centers content using flexbox, scales images using object-fit: contain, and maintains the original aspect ratio.
|
||||
- Fixed URL encoding for subfolder paths so that images in subfolders (e.g. NewFolder2/Vita) load correctly without encoding slashes.
|
||||
- Download ZIP Modal Updates:
|
||||
- Replaced the prompt-based download ZIP with a modal dialog that allows users to enter a custom name for the ZIP file.
|
||||
- Updated the modal logic to ensure proper flow (cancel/confirm) and pass the custom filename to the download process.
|
||||
- Folder URL Handling:
|
||||
- Modified the folder path construction in the file list rendering to split folder names into segments and encode each segment individually. This prevents encoding of slashes, ensuring correct URLs for files in subfolders.
|
||||
- General UI & Functionality:
|
||||
- Ensured that all global functions (e.g., toggleRowSelection, updateRowHighlight, and sortFiles) are declared and attached to window so that inline event handlers can access them.
|
||||
- Maintained responsive design, preserving existing features such as pagination, sorting, batch operations (delete, copy, move), and folder management.
|
||||
- Updated event listener initialization to work with new modal features and ensure smooth UI interactions.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/8/2025
|
||||
|
||||
- Validation was added in endpoints.
|
||||
- Toast notifications were implemented in domUtils.js and integrated throughout the app.
|
||||
- Modals replaced inline prompts and confirms for rename, create, delete, copy, and move actions.
|
||||
- Folder tree UI was added and improved to be interactive plus reflect the current state after actions.
|
||||
|
||||
---
|
||||
|
||||
## changes 3/7/2025
|
||||
|
||||
- **Module Refactoring:**
|
||||
- Split the original `utils.js` into multiple ES6 modules for network requests, DOM utilities, file management, folder management, uploads, and authentication.
|
||||
- Converted all code to ES6 modules with `import`/`export` syntax and exposed necessary functions globally.
|
||||
- **File List Rendering & Pagination:**
|
||||
- Implemented pagination in `fileManager.js` to allow displaying 10, 20, 50, or 100 items per page.
|
||||
- Added global functions (`changePage` and `changeItemsPerPage`) for pagination control.
|
||||
- Added a pagination control section below the file list table.
|
||||
- **Date Sorting Enhancements:**
|
||||
- Created a custom date parser (`parseCustomDate`) to convert date strings.
|
||||
- Adjusted the parser to handle two-digit years by adding 2000.
|
||||
- Integrated the parser into the sorting function to reliably sort “Date Modified” and “Upload Date” columns.
|
||||
- **File Upload Improvements:**
|
||||
- Enabled multi-file uploads with individual progress tracking (visible for the first 10 files).
|
||||
- Ensured that the file list refreshes immediately after uploads complete.
|
||||
- Kept the upload progress list visible for a configurable delay to allow users to verify upload success.
|
||||
- Reattached event listeners after the file list is re-rendered.
|
||||
- **File Action Buttons:**
|
||||
- Unified button state management so that Delete, Copy, and Move buttons remain visible as long as files exist, and are only enabled when files are selected.
|
||||
- Modified the logic in `updateFileActionButtons` and removed conflicting code from `initFileActions`.
|
||||
- Ensured that the folder dropdown for copy/move is hidden when no files exist.
|
||||
- **Rename Functionality:**
|
||||
- Added a “Rename” button to the Actions column for every file.
|
||||
- Implemented a `renameFile` function that prompts for a new name, calls a backend script (`renameFile.php`) to perform the rename, updates metadata, and refreshes the file list.
|
||||
- **Responsive & UI Tweaks:**
|
||||
- Applied CSS media queries to hide secondary columns on small screens.
|
||||
- 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.*
|
||||
|
||||
---
|
||||
|
||||
## Changes 3/4/2025
|
||||
|
||||
- Copy & Move functionality added
|
||||
- Header Layout
|
||||
- Modal Popups (Edit, Add User, Remove User) changes
|
||||
- Consolidated table styling
|
||||
- CSS Consolidation
|
||||
- assets folder
|
||||
- additional changes and fixes
|
||||
|
||||
---
|
||||
|
||||
## Changes 3/3/2025
|
||||
|
||||
- folder management added
|
||||
- some refactoring
|
||||
- config added USERS_DIR & USERS_FILE
|
||||
153
CONTRIBUTING.md
@@ -11,6 +11,7 @@ Thank you for your interest in contributing to FileRise! We appreciate your help
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Documentation](#documentation)
|
||||
- [Questions and Support](#questions-and-support)
|
||||
- [Adding New Language Translations](#adding-new-language-translations)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -25,7 +26,7 @@ Thank you for your interest in contributing to FileRise! We appreciate your help
|
||||
```
|
||||
|
||||
3. **Set Up a Local Environment**
|
||||
FileRise runs on a standard LAMP stack. Ensure you have PHP, Apache, and the necessary dependencies installed. For frontend development, Node.js may be required for build tasks if applicable.
|
||||
FileRise runs on a standard LAMP stack. Ensure you have PHP, Apache, and the necessary dependencies installed.
|
||||
|
||||
4. **Configuration**
|
||||
Copy any example configuration files (if provided) and adjust them as needed for your local setup.
|
||||
@@ -87,6 +88,156 @@ If you notice any areas in the documentation that need improvement or updating,
|
||||
|
||||
If you have any questions, ideas, or need support, please open an issue or join our discussion on [GitHub Discussions](https://github.com/error311/FileRise/discussions). We’re here to help and appreciate your contributions.
|
||||
|
||||
## Adding New Language Translations
|
||||
|
||||
FileRise supports internationalization (i18n) and localization via a central translation file (`i18n.js`). If you would like to contribute a new language translation, please follow these steps:
|
||||
|
||||
1. **Update `i18n.js`:**
|
||||
Open the `i18n.js` file located in the `js` directory. Within the `translations` object, add a new property using the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) as the key. Copy the structure from an existing language block and translate each key.
|
||||
|
||||
**Example (for German):**
|
||||
|
||||
```js
|
||||
de: {
|
||||
"please_log_in_to_continue": "Bitte melden Sie sich an, um fortzufahren.",
|
||||
"no_files_selected": "Keine Dateien ausgewählt.",
|
||||
"confirm_delete_files": "Sind Sie sicher, dass Sie {count} ausgewählte Datei(en) löschen möchten?",
|
||||
"element_not_found": "Element mit der ID \"{id}\" wurde nicht gefunden.",
|
||||
"search_placeholder": "Suche nach Dateien oder Tags...",
|
||||
"file_name": "Dateiname",
|
||||
"date_modified": "Änderungsdatum",
|
||||
"upload_date": "Hochladedatum",
|
||||
"file_size": "Dateigröße",
|
||||
"uploader": "Hochgeladen von",
|
||||
"enter_totp_code": "Geben Sie den TOTP-Code ein",
|
||||
"use_recovery_code_instead": "Verwenden Sie stattdessen den Wiederherstellungscode",
|
||||
"enter_recovery_code": "Geben Sie den Wiederherstellungscode ein",
|
||||
"editing": "Bearbeitung",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"no_files_found": "Keine Dateien gefunden.",
|
||||
"switch_to_table_view": "Zur Tabellenansicht wechseln",
|
||||
"switch_to_gallery_view": "Zur Galerieansicht wechseln",
|
||||
"share_file": "Datei teilen",
|
||||
"set_expiration": "Ablauf festlegen:",
|
||||
"password_optional": "Passwort (optional):",
|
||||
"generate_share_link": "Freigabelink generieren",
|
||||
"shareable_link": "Freigabelink:",
|
||||
"copy_link": "Link kopieren",
|
||||
"tag_file": "Datei taggen",
|
||||
"tag_name": "Tagname:",
|
||||
"tag_color": "Tagfarbe:",
|
||||
"save_tag": "Tag speichern",
|
||||
"files_in": "Dateien in",
|
||||
"light_mode": "Heller Modus",
|
||||
"dark_mode": "Dunkler Modus",
|
||||
"upload_instruction": "Ziehen Sie Dateien/Ordner hierher oder klicken Sie auf 'Dateien auswählen'",
|
||||
"no_files_selected_default": "Keine Dateien ausgewählt",
|
||||
"choose_files": "Dateien auswählen",
|
||||
"delete_selected": "Ausgewählte löschen",
|
||||
"copy_selected": "Ausgewählte kopieren",
|
||||
"move_selected": "Ausgewählte verschieben",
|
||||
"tag_selected": "Ausgewählte taggen",
|
||||
"download_zip": "Zip herunterladen",
|
||||
"extract_zip": "Zip entpacken",
|
||||
"preview": "Vorschau",
|
||||
"edit": "Bearbeiten",
|
||||
"rename": "Umbenennen",
|
||||
"trash_empty": "Papierkorb ist leer.",
|
||||
"no_trash_selected": "Keine Elemente im Papierkorb für die Wiederherstellung ausgewählt.",
|
||||
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"logout": "Abmelden",
|
||||
"change_password": "Passwort ändern",
|
||||
"restore_text": "Wiederherstellen oder",
|
||||
"delete_text": "Papierkorbeinträge löschen",
|
||||
"restore_selected": "Ausgewählte wiederherstellen",
|
||||
"restore_all": "Alle wiederherstellen",
|
||||
"delete_selected_trash": "Ausgewählte löschen",
|
||||
"delete_all": "Alle löschen",
|
||||
"upload_header": "Dateien/Ordner hochladen",
|
||||
|
||||
// Folder Management keys:
|
||||
"folder_navigation": "Ordnernavigation & Verwaltung",
|
||||
"create_folder": "Ordner erstellen",
|
||||
"create_folder_title": "Ordner erstellen",
|
||||
"enter_folder_name": "Geben Sie den Ordnernamen ein",
|
||||
"cancel": "Abbrechen",
|
||||
"create": "Erstellen",
|
||||
"rename_folder": "Ordner umbenennen",
|
||||
"rename_folder_title": "Ordner umbenennen",
|
||||
"rename_folder_placeholder": "Neuen Ordnernamen eingeben",
|
||||
"delete_folder": "Ordner löschen",
|
||||
"delete_folder_title": "Ordner löschen",
|
||||
"delete_folder_message": "Sind Sie sicher, dass Sie diesen Ordner löschen möchten?",
|
||||
"folder_help": "Ordnerhilfe",
|
||||
"folder_help_item_1": "Klicken Sie auf einen Ordner, um dessen Dateien anzuzeigen.",
|
||||
"folder_help_item_2": "Verwenden Sie [-] um zu minimieren und [+] um zu erweitern.",
|
||||
"folder_help_item_3": "Klicken Sie auf \"Ordner erstellen\", um einen Unterordner hinzuzufügen.",
|
||||
"folder_help_item_4": "Um einen Ordner umzubenennen oder zu löschen, wählen Sie ihn und klicken Sie auf die entsprechende Schaltfläche.",
|
||||
|
||||
// File List keys:
|
||||
"file_list_title": "Dateien in (Root)",
|
||||
"delete_files": "Dateien löschen",
|
||||
"delete_selected_files_title": "Ausgewählte Dateien löschen",
|
||||
"delete_files_message": "Sind Sie sicher, dass Sie die ausgewählten Dateien löschen möchten?",
|
||||
"copy_files": "Dateien kopieren",
|
||||
"copy_files_title": "Ausgewählte Dateien kopieren",
|
||||
"copy_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu kopieren:",
|
||||
"move_files": "Dateien verschieben",
|
||||
"move_files_title": "Ausgewählte Dateien verschieben",
|
||||
"move_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu verschieben:",
|
||||
"move": "Verschieben",
|
||||
"extract_zip_button": "Zip entpacken",
|
||||
"download_zip_title": "Ausgewählte Dateien als Zip herunterladen",
|
||||
"download_zip_prompt": "Geben Sie einen Namen für die Zip-Datei ein:",
|
||||
"zip_placeholder": "dateien.zip",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Anmelden",
|
||||
"remember_me": "Angemeldet bleiben",
|
||||
"login_oidc": "Mit OIDC anmelden",
|
||||
"basic_http_login": "HTTP-Basisauthentifizierung verwenden",
|
||||
|
||||
// Change Password keys:
|
||||
"change_password_title": "Passwort ändern",
|
||||
"old_password": "Altes Passwort",
|
||||
"new_password": "Neues Passwort",
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
|
||||
// Add User keys:
|
||||
"create_new_user_title": "Neuen Benutzer erstellen",
|
||||
"username": "Benutzername:",
|
||||
"password": "Passwort:",
|
||||
"grant_admin": "Admin-Rechte vergeben",
|
||||
"save_user": "Benutzer speichern",
|
||||
|
||||
// Remove User keys:
|
||||
"remove_user_title": "Benutzer entfernen",
|
||||
"select_user_remove": "Wählen Sie einen Benutzer zum Entfernen:",
|
||||
"delete_user": "Benutzer löschen",
|
||||
|
||||
// Rename File keys:
|
||||
"rename_file_title": "Datei umbenennen",
|
||||
"rename_file_placeholder": "Neuen Dateinamen eingeben",
|
||||
|
||||
// Custom Confirm Modal keys:
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"delete": "Löschen",
|
||||
"download": "Herunterladen",
|
||||
"upload": "Hochladen",
|
||||
"copy": "Kopieren",
|
||||
"extract": "Entpacken",
|
||||
|
||||
// Dark Mode Toggle
|
||||
"dark_mode_toggle": "Dunkler Modus"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping to improve FileRise and happy coding!
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 SeNS
|
||||
Copyright (c) 2025 FileRise
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
468
README.md
@@ -1,374 +1,202 @@
|
||||
# FileRise - Elevate your File Management
|
||||
# FileRise
|
||||
|
||||
**Video demo:**
|
||||
**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.
|
||||
|
||||
https://github.com/user-attachments/assets/9546a76b-afb0-4068-875a-0eab478b514d
|
||||
**4/3/2025 Video demo:**
|
||||
|
||||
<https://github.com/user-attachments/assets/221f6a53-85f5-48d4-9abe-89445e0af90e>
|
||||
|
||||
**Dark mode:**
|
||||

|
||||
|
||||
changelogs available here: <https://github.com/error311/FileRise-docker/>
|
||||
|
||||
FileRise is a lightweight, secure, self-hosted web application for uploading, syntax-highlight editing, drag & drop file management, and more. Built with an Apache/PHP backend and a modern JavaScript (ES6 modules) frontend, it offers a responsive and dynamic interface designed to simplify file handling. As an alternative to solutions like FileGator, TinyFileManager, or ProjectSend, FileRise provides an easy-to-set-up experience ideal for document management, image galleries, firmware hosting, and other file-intensive applications.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||
- **Multiple File/Folder Uploads with Progress (Resumable.js Integration):**
|
||||
- Users can effortlessly upload multiple files and folders simultaneously by either selecting them through the file picker or dragging and dropping them directly into the interface.
|
||||
- **Chunked Uploads:** Files are uploaded in configurable chunks (default set as 3 MB) to efficiently handle large files.
|
||||
- **Pause, Resume, and Retry:** Uploads can be paused and resumed at any time, with support for retrying failed chunks.
|
||||
- **Real-Time Progress:** Each file shows an individual progress bar that displays percentage complete and upload speed.
|
||||
- **File & Folder Grouping:** When many files are dropped, files are automatically grouped into a scrollable wrapper, ensuring the interface remains clean.
|
||||
- **Secure Uploads:** All uploads integrate CSRF token validation and other security checks.
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. No more failed transfers – FileRise will pick up where it left off if your connection drops.
|
||||
|
||||
- **Built-in File Editing & Renaming:**
|
||||
- Text-based files (e.g., .txt, .html, .js) can be opened and edited in a modal window using CodeMirror for:
|
||||
- Syntax highlighting
|
||||
- Line numbering
|
||||
- Adjustable font sizes
|
||||
- Files can be renamed directly through the interface.
|
||||
- The renaming functionality now supports names with parentheses and checks for duplicate names, automatically generating a unique name (e.g., appending “ (1)”) when needed.
|
||||
- Folder-specific metadata is updated accordingly.
|
||||
- **Enhanced File Editing Check:** Files with a Content-Length of 0 KB are now allowed to be edited.
|
||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can even download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
||||
|
||||
- **Built-in File Preview:**
|
||||
- Users can quickly preview images, videos, audio and PDFs directly in modal popups without leaving the page.
|
||||
- The preview modal supports inline display of images (with proper scaling) and videos with playback controls.
|
||||
- Navigation (prev/next) within image previews is supported for a seamless browsing experience.
|
||||
- 🗃️ **Folder Sharing & File Sharing:** Easily share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls, and file sizes are displayed in MB for clarity. Share files with others using one-time or expiring public links (with password protection if desired) – convenient for sending individual files without exposing the whole app.
|
||||
|
||||
- **Gallery (Grid) View:**
|
||||
- In addition to the traditional table view, users can toggle to a gallery view that arranges image thumbnails in a grid layout.
|
||||
- The gallery view offers multiple column options (e.g., 3, 4, or 5 columns) so that users can choose the layout that best fits their screen.
|
||||
- Action buttons (Download, Edit, Rename, Share) appear beneath each thumbnail for quick access.
|
||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal – no need to download just to see them. Edit text/code files right in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers. Great for config files or notes – tweak and save changes without leaving FileRise.
|
||||
|
||||
- **Batch Operations (Delete/Copy/Move/Download/Extract Zip):**
|
||||
- **Delete Files:** Delete multiple files at once.
|
||||
- **Copy Files:** Copy selected files to another folder with a unique-naming feature to prevent overwrites.
|
||||
- **Move Files:** Move selected files to a different folder, automatically generating a unique filename if needed to avoid data loss.
|
||||
- **Download Files as ZIP:** Download selected files as a ZIP archive. Users can specify a custom name for the ZIP file via a modal dialog.
|
||||
- **Extract Zip:** When one or more ZIP files are selected, users can extract the archive(s) directly into the current folder.
|
||||
- **Drag & Drop (File Movement):** Easily move files by selecting them from the file list and dragging them onto your desired folder in the folder tree or breadcrumb. When you drop the files onto a folder, the system automatically moves them, updating your file organization in one seamless action.
|
||||
- **Enhanced Context Menu & Keyboard Shortcuts:**
|
||||
- **Right-Click Context Menu:**
|
||||
- A custom context menu appears on right-clicking within the file list.
|
||||
- For multiple selections, options include Delete Selected, Copy Selected, Move Selected, Download Zip, and (if applicable) Extract Zip.
|
||||
- When exactly one file is selected, additional options (Preview, Edit [if editable], Rename, and Tag File) are available.
|
||||
- **Keyboard Shortcut for Deletion:**
|
||||
- A global keydown listener detects Delete/Backspace key presses (when no input is focused) to trigger the delete operation.
|
||||
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using our indexed real-time search. Easily switch to Advanced Search mode to enable fuzzy matching not only across file names, tags, and uploader fields but also within the content of text files—helping you find that “important” document even if you make a typo or need to search deep within the file.
|
||||
|
||||
- **File Tagging and Global Tag Management:**
|
||||
- **Context Menu Tagging:**
|
||||
- Single-file tagging: “Tag File” option in the right-click menu opens a modal to add a tag (with name and color) to the file.
|
||||
- Multi-file tagging: When multiple files are selected, a “Tag Selected” option opens a multi‑file tagging modal to apply the same tag to all selected files.
|
||||
- **Tagging Modals & Custom Dropdown:**
|
||||
- Dedicated modals provide an interface for adding and updating tags.
|
||||
- A custom dropdown in each modal displays available global tags with a colored preview and a remove icon.
|
||||
- **Global Tag Store:**
|
||||
- Tags are stored globally (persisted in a JSON file) for reuse across files and sessions.
|
||||
- New tags added to any file are automatically added to the global store.
|
||||
- Users can remove a global tag directly from the dropdown, which removes it from the available tag list for all files.
|
||||
- **Unified Search Filtering:**
|
||||
- The single search box now filters files based on both file names and tag names (case‑insensitive).
|
||||
- 🔒 **User Authentication & User Permissions:** Secure your portal with username/password login. Supports multiple users – create user accounts (admin UI provided) for family or team members. User permissions such as User “Folder Only” feature assigns each user a dedicated folder within the root directory, named after their username, restricting them from viewing or modifying other directories. User Read Only and Disable Upload are additional permissions. FileRise also integrates with Single Sign-On (OIDC) providers (e.g., OAuth2/OIDC for Google/Authentik/Keycloak) and offers optional TOTP two-factor auth for extra security.
|
||||
|
||||
- **Folder Management:**
|
||||
- Organize files into folders and subfolders with the ability to create, rename, and delete folders.
|
||||
- A dynamic folder tree in the UI allows users to navigate directories easily, with real-time updates.
|
||||
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), updated with operations like copy/move/rename.
|
||||
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder; supports drag & drop for moving files.
|
||||
- **Folder Manager Context Menu:**
|
||||
- Right-clicking on a folder brings up a custom context menu with options for creating, renaming, and deleting folders.
|
||||
- **Keyboard Shortcut for Folder Deletion:**
|
||||
- A global key listener (Delete/Backspace) triggers folder deletion with safeguards to prevent deletion of the root folder.
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** FileRise is mobile-friendly out of the box – manage files from your phone or tablet with a responsive layout. Choose between Dark mode or Light theme, or let it follow your system preference. The interface remembers your preferences (layout, items per page, last visited folder, etc.) for a personalized experience each time.
|
||||
|
||||
- **Sorting & Pagination:**
|
||||
- Files can be sorted by name, modified date, upload date, file size, or uploader.
|
||||
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” buttons.
|
||||
- 🌐 **Internationalization & Localization:** FileRise supports multiple languages via an integrated i18n system. Users can switch languages through a user panel dropdown, and their choice is saved in local storage for a consistent experience across sessions. Currently available in English, Spanish, and French—please report any translation issues you encounter.
|
||||
|
||||
- **Share Link Functionality:**
|
||||
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and 1 day) and optional password protection.
|
||||
- Share links are stored in a JSON file with details including folder, file, expiration timestamp, and hashed password.
|
||||
- The share endpoint validates tokens, expiration, and password before serving files (or forcing downloads).
|
||||
- The share URL is configurable via environment variables or auto-detected from the server.
|
||||
- 🗑️ **Trash & File Recovery:** Mistakenly deleted files? No worries – deleted items go to the Trash instead of immediate removal. Admins can restore files from Trash or empty it to free space. FileRise auto-purges old trash entries (default 3 days) to keep your storage tidy.
|
||||
|
||||
- **User Authentication & Management:**
|
||||
- Secure, session-based authentication protects the file manager.
|
||||
- Admin users can add or remove users through the interface.
|
||||
- Passwords are hashed using PHP’s `password_hash()` for security.
|
||||
- All state-changing endpoints include CSRF token validation.
|
||||
- Password change functionality is supported for all users.
|
||||
- Basic Auth is available for login.
|
||||
- **Persistent Login (Remember Me) with Encrypted Tokens:**
|
||||
- Users can remain logged in across sessions securely.
|
||||
- Persistent tokens are encrypted using AES‑256‑CBC before being stored in a JSON file.
|
||||
- On auto-login, tokens are decrypted on the server to re-establish user sessions without re-authentication.
|
||||
- ⚙️ **Lightweight & Self-Contained:** FileRise runs on PHP 8.1+ with no external database required – data is stored in files (users, metadata) for simplicity. It’s a single-folder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our pre-built image for a hassle-free setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
|
||||
|
||||
- **Responsive, Dynamic & Persistent UI:**
|
||||
- The interface is mobile-friendly and adapts to various screen sizes by hiding non-critical columns on small devices.
|
||||
- Asynchronous updates (via Fetch API and XMLHttpRequest) keep the UI responsive without full page reloads.
|
||||
- Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth, customized user experience.
|
||||
|
||||
- **Dark Mode/Light Mode:**
|
||||
- The application automatically adapts to the operating system’s theme preference by default, with a manual toggle available.
|
||||
- Dark mode provides a darker background with lighter text, and UI elements (including the CodeMirror editor) are adjusted for optimal readability in low-light conditions.
|
||||
- Light mode maintains a bright interface suitable for well-lit environments.
|
||||
|
||||
- **Server & Security Enhancements:**
|
||||
- Apache (or .htaccess) configurations disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized file browsing.
|
||||
- Direct access to sensitive files (e.g., `users.txt`) is restricted via .htaccess rules.
|
||||
- A proxy download mechanism (via endpoints like `download.php` and `downloadZip.php`) routes all file downloads through PHP, ensuring session and CSRF token validation before file access.
|
||||
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments.
|
||||
|
||||
- **Trash Management with Restore & Delete:**
|
||||
- **Trash Storage & Metadata:**
|
||||
- Deleted files are moved to a designated “Trash” folder rather than being immediately removed.
|
||||
- Metadata is stored in a JSON file (`trash.json`) that records:
|
||||
- Original folder and file name
|
||||
- Timestamp when the file was trashed
|
||||
- Uploader information (and optionally who deleted it)
|
||||
- Additional metadata (e.g., file type)
|
||||
- **Restore Functionality:**
|
||||
- Admins can view trashed files in a modal and restore individual or all files back to their original location (with conflict checks).
|
||||
- **Delete Functionality:**
|
||||
- Users can permanently delete trashed files via:
|
||||
- **Delete Selected:** Remove specific files from the Trash and update `trash.json`.
|
||||
- **Delete All:** Permanently remove every file from the Trash after confirmation.
|
||||
- **Auto-Purge Mechanism:**
|
||||
- The system automatically purges files in the Trash older than three days, managing storage and preventing accumulation of outdated files.
|
||||
- **Trash UI:**
|
||||
- The trash modal displays file name, uploader/deleter, and trashed date/time.
|
||||
- Material icons with tooltips represent restore and delete actions.
|
||||
|
||||
- **Drag & Drop Cards with Dedicated Drop Zones:**
|
||||
- **Sidebar Drop Zone:**
|
||||
- Cards (e.g., upload or folder management) can be dragged into a dedicated sidebar drop zone for quick access to frequently used operations.
|
||||
- The sidebar drop zone expands dynamically to accept drops anywhere within its visual area.
|
||||
- **Top Bar Drop Zone:**
|
||||
- A top drop zone is available for reordering or managing cards quickly.
|
||||
- Dragging a card to the top drop zone provides immediate visual feedback, ensuring a fluid and customizable workflow.
|
||||
- **Seamless Interaction:**
|
||||
- Both drop zones support smooth drag-and-drop interactions with animations and pointer event adjustments, ensuring reliable card placement regardless of screen position.
|
||||
|
||||
## 🔒 Admin Panel, TOTP & OpenID Connect (OIDC) Integration
|
||||
|
||||
- **Flexible Authentication:**
|
||||
- Supports multiple authentication methods including Form-based Login, Basic Auth, OpenID Connect (OIDC), and TOTP-based Two-Factor Authentication.
|
||||
- Ensures continuous secure access by allowing administrators to disable only two of the available login options at any time.
|
||||
|
||||
- **Secure OIDC Authentication:**
|
||||
- Seamlessly integrates with OIDC providers (e.g., Keycloak, Okta).
|
||||
- Provides admin-configurable OIDC settings—including Provider URL, Client ID, Client Secret, and Redirect URI.
|
||||
- Stores all sensitive configurations in an encrypted JSON file.
|
||||
|
||||
- **TOTP Two-Factor Authentication:**
|
||||
- Enhances security by integrating Time-based One-Time Password (TOTP) functionality.
|
||||
- The new User Panel automatically displays the TOTP setup modal when users enable TOTP, presenting a QR code for easy configuration in authenticator apps.
|
||||
- Administrators can customize a global OTPAuth URL template for consistent TOTP provisioning across accounts.
|
||||
|
||||
- **Dynamic Admin Panel:**
|
||||
- Features an intuitive interface with Material Icons for quick recognition and access.
|
||||
- Allows administrators to manage authentication settings, user management, and login methods in real time.
|
||||
- Includes real-time validation that prevents the accidental disabling of all authentication methods simultaneously.
|
||||
- User Permissions options
|
||||
- Folder Only gives user their own root folder
|
||||
- Read Only makes it so user can only read the files
|
||||
- Disable upload
|
||||
(For a full list of features and detailed changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [changelog](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or the [releases](https://github.com/error311/FileRise/releases) pages.)
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
## Live Demo
|
||||
|
||||
**Light mode:**
|
||||

|
||||
|
||||
**Light mode:**
|
||||

|
||||
|
||||
**Dark mode default:**
|
||||

|
||||
|
||||
**Dark editor:**
|
||||

|
||||
|
||||
**Light preview**
|
||||

|
||||
|
||||
**Restore or Delete Trash:**
|
||||

|
||||
|
||||
**Dark Login page:**
|
||||

|
||||
|
||||
**Gallery view:**
|
||||

|
||||
|
||||
**iphone screenshots:**
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-iphone.png" width="45%">
|
||||
<img src="https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/light-preview-iphone.png" width="45%">
|
||||
</p>
|
||||
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). *The demo is read-only for security*. Explore the interface, switch themes, preview files, and see FileRise in action!
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Manual Installation
|
||||
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. **Clone or Download the Repository:**
|
||||
- **Clone:**
|
||||
### 1. Running with Docker (Recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
```
|
||||
If you have Docker installed, you can get FileRise up and running in minutes:
|
||||
|
||||
- **Download:**
|
||||
Download the latest release from the GitHub releases page and extract it into your desired directory.
|
||||
- **Pull the image from Docker Hub:**
|
||||
|
||||
2. **Deploy to Your Web Server:**
|
||||
- Place the project files in your Apache web directory (e.g., `/var/www/html`).
|
||||
- Ensure PHP 8.1+ is installed along with the required extensions (`php-json`, `php-curl`, `php-zip`, etc.).
|
||||
``` bash
|
||||
docker pull error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
3. **Install Composer Dependencies (Required for OIDC Support):**
|
||||
- Install Composer if you haven't already ([Installation Guide](https://getcomposer.org/download/)).
|
||||
- Navigate to the project directory and run:
|
||||
*(For Apple Silicon (M1/M2) users, use --platform linux/amd64 tag until multi-arch support is added.)*
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
- **Run a container:**
|
||||
|
||||
- This step will install necessary dependencies like `jumbojett/openid-connect-php` and `phpseclib/phpseclib`.
|
||||
``` bash
|
||||
docker run -d \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
--name filerise \
|
||||
error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
4. **Directory Setup & Permissions:**
|
||||
- Create the following directories if they do not exist, and set appropriate permissions:
|
||||
- `uploads/` – for file storage.
|
||||
- `users/` – to store `users.txt` (user authentication data).
|
||||
- `metadata/` – for storing `file_metadata.json` and other metadata.
|
||||
- Example commands:
|
||||
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.
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/uploads /var/www/users /var/www/metadata
|
||||
chmod -R 775 /var/www/uploads /var/www/users /var/www/metadata
|
||||
```
|
||||
- **Using Docker Compose:**
|
||||
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
|
||||
|
||||
5. **Configure Apache:**
|
||||
- Ensure that directory indexing is disabled (using `Options -Indexes` in your `.htaccess` or Apache configuration).
|
||||
- Make sure the Apache configuration allows URL rewriting if needed.
|
||||
``` yaml
|
||||
version: '3'
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
TIMEZONE: "UTC"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||
volumes:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
```
|
||||
|
||||
6. **Configuration File:**
|
||||
- Open `config.php` and adjust the following constants as necessary:
|
||||
- `BASE_URL`: Set this to your web app’s base URL.
|
||||
- `UPLOAD_DIR`: Adjust the directory path for uploads.
|
||||
- `TIMEZONE`: Set to your preferred timezone.
|
||||
- `TOTAL_UPLOAD_SIZE`: Ensure it matches PHP’s `upload_max_filesize` and `post_max_size` settings in your `php.ini`.
|
||||
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.
|
||||
|
||||
### Initial Setup Instructions
|
||||
**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 Launch Admin Setup:**
|
||||
On first launch, if no users exist, the application will enter a setup mode. You will be prompted to create an admin user. This is handled automatically by the application (e.g., via a “Create Admin” form).
|
||||
**Note:** No default credentials are provided. You must create the first admin account to log in and manage additional users.
|
||||
### 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).
|
||||
|
||||
``` 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).
|
||||
|
||||
- **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.) If you skip this, FileRise will still work, but OIDC login won’t be available.
|
||||
|
||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
||||
|
||||
``` bash
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate 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.
|
||||
|
||||
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
|
||||
|
||||
- `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.
|
||||
|
||||
- **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.
|
||||
|
||||
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!
|
||||
|
||||
---
|
||||
|
||||
## Docker Usage
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
For users who prefer containerization, a Docker image is available.
|
||||
- **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are all set high enough. For extremely large files, you might also need to increase max_execution_time in PHP or rely on the resumable upload feature in smaller chunks.
|
||||
|
||||
**Note:** The Docker image already includes Composer dependencies pre-installed (including OIDC support).
|
||||
- **How to enable HTTPS?** FileRise itself doesn’t handle TLS. Run it behind a reverse proxy like Nginx, Caddy, or Apache with SSL, or use Docker with a companion like nginx-proxy or Caddy. Set `SECURE="true"` env var in Docker so FileRise knows to generate https links.
|
||||
|
||||
### Quickstart
|
||||
- **Changing Admin or resetting password:** Admin can change any user’s password via the UI (User Management section). If you lose admin access, you can edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and then restart the app to trigger the setup flow again.
|
||||
|
||||
1. **Pull the Docker Image:**
|
||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set for `UPLOAD_DIR`). Within it, files are organized in the folder structure you see in the app. Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata`.json and trash metadata in `metadata/trash.json`, etc. Regular backups of these folders is recommended if the data is important.
|
||||
|
||||
```bash
|
||||
docker pull error311/filerise-docker:latest
|
||||
```
|
||||
- **Updating FileRise:** If using Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace the files (preserve your `config.php` and the uploads/users/metadata folders). Clear your browser cache if you have issues after an update (in case CSS/JS changed).
|
||||
|
||||
macos M series:
|
||||
|
||||
```bash
|
||||
docker pull --platform linux/x86_64 error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
2. **Run the Container:**
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 80:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-v /path/to/your/uploads:/var/www/uploads \
|
||||
-v /path/to/your/users:/var/www/users \
|
||||
-v /path/to/your/metadata:/var/www/metadata \
|
||||
--name FileRise \
|
||||
error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
3. **Using Docker Compose:**
|
||||
|
||||
Create a docker-compose.yml file with the following content:
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: error311/filerise-docker:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
TIMEZONE: "America/New_York"
|
||||
TOTAL_UPLOAD_SIZE: "5G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
volumes:
|
||||
- /path/to/your/uploads:/var/www/uploads
|
||||
- /path/to/your/users:/var/www/users
|
||||
- /path/to/your/metadata:/var/www/metadata
|
||||
```
|
||||
|
||||
**Then start the container with:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Guidance
|
||||
|
||||
The `config.php` file contains several key constants that may need adjustment for your deployment:
|
||||
|
||||
- **BASE_URL:**
|
||||
Set to the URL where your application is hosted (e.g., `http://yourdomain.com/uploads/`).
|
||||
|
||||
- **UPLOAD_DIR, USERS_DIR, META_DIR:**
|
||||
Define the directories for uploads, user data, and metadata. Adjust these to match your server environment or Docker volume mounts.
|
||||
|
||||
- **TIMEZONE & DATE_TIME_FORMAT:**
|
||||
Set according to your regional settings.
|
||||
|
||||
- **TOTAL_UPLOAD_SIZE:**
|
||||
Defines the maximum upload size (default is `5G`). Ensure that PHP’s `upload_max_filesize` and `post_max_size` in your `php.ini` are consistent with this setting. The startup script (`start.sh`) updates PHP limits at runtime based on this value.
|
||||
|
||||
- **Environment Variables (Docker):**
|
||||
The Docker image supports overriding configuration via environment variables. For example, you can set `SECURE`, `SHARE_URL`, `PERSISTENT_TOKENS_KEY` and port settings via the container’s environment.
|
||||
|
||||
---
|
||||
|
||||
## Additional Information
|
||||
|
||||
- **Security:**
|
||||
All state-changing endpoints use CSRF token validation. Ensure that sessions and tokens are correctly configured as per your deployment environment.
|
||||
|
||||
- **Permissions:**
|
||||
Both manual and Docker installations include steps to ensure that file and directory permissions are set correctly for the web server to read and write as needed.
|
||||
|
||||
- **Logging & Troubleshooting:**
|
||||
Check Apache logs (located in `/var/log/apache2/`) for troubleshooting any issues during deployment or operation.
|
||||
For more Q&A or to ask for help, please check the Discussions or open an issue.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please check out our [Contributing Guidelines](CONTRIBUTING.md) before getting started.
|
||||
Contributions are welcome! If you have ideas for new features or have found a bug, feel free to open an issue. Check out the [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. You can also join the conversation in GitHub Discussions or on Reddit (see links below) to share feedback and suggestions.
|
||||
|
||||
Areas where you can help: translations, bug fixes, UI improvements, or building integration with other services. If you like FileRise, giving the project a ⭐ star ⭐ on GitHub is also a much-appreciated contribution!
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### PHP Libraries
|
||||
|
||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
|
||||
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
|
||||
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
|
||||
|
||||
### Client-Side Libraries
|
||||
|
||||
- **Google Fonts** – [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
|
||||
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
|
||||
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) – For code editing functionality.
|
||||
- **[Resumable.js](http://www.resumablejs.com/)** (v1.1.0) – For file uploads.
|
||||
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) – For sanitizing HTML.
|
||||
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) – For indexed, fuzzy searching.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is open-source under the MIT License. That means you’re free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!
|
||||
|
||||
33
SECURITY.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
FileRise is actively maintained. Only supported versions will receive security updates. For details on which versions are currently supported, please see the [Release Notes](https://github.com/error311/FileRise/releases).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please do not open a public issue. Instead, follow these steps:
|
||||
|
||||
1. **Email Us Privately:**
|
||||
Send an email to [security@filerise.net](mailto:security@filerise.net) with the subject line “[FileRise] Security Vulnerability Report”.
|
||||
|
||||
2. **Include Details:**
|
||||
Provide a detailed description of the vulnerability, steps to reproduce it, and any other relevant information (e.g., affected versions, screenshots, logs).
|
||||
|
||||
3. **Secure Communication (Optional):**
|
||||
If you wish to discuss the vulnerability securely, you can use our PGP key. You can obtain our PGP key by emailing us, and we will send it upon request.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- **Acknowledgement:**
|
||||
We will acknowledge receipt of your report within 48 hours.
|
||||
|
||||
- **Resolution Timeline:**
|
||||
We aim to fix confirmed vulnerabilities within 30 days. In cases where a delay is necessary, we will communicate updates to you directly.
|
||||
|
||||
- **Public Disclosure:**
|
||||
After a fix is available, details of the vulnerability will be disclosed publicly in a way that does not compromise user security.
|
||||
|
||||
## Additional Information
|
||||
|
||||
We appreciate responsible disclosure of vulnerabilities and thank all researchers who help keep FileRise secure. For any questions related to this policy, please contact us at [admin@filerise.net](mailto:admin@filerise.net).
|
||||
@@ -49,7 +49,7 @@ if (!$newUsername || !$newPassword) {
|
||||
}
|
||||
|
||||
// Validate username using preg_match (allow letters, numbers, underscores, dashes, and spaces).
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $newUsername)) {
|
||||
if (!preg_match(REGEX_USER, $newUsername)) {
|
||||
echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
142
auth.php
@@ -1,16 +1,27 @@
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
|
||||
use RobThree\Auth\Algorithm;
|
||||
use RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Global exception handler: logs errors and returns a generic error message.
|
||||
set_exception_handler(function ($e) {
|
||||
error_log("Unhandled exception: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Internal Server Error"]);
|
||||
exit();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Get the user's role from users.txt.
|
||||
*/
|
||||
function getUserRole($username) {
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(":", trim($line));
|
||||
if (count($parts) >= 3 && $parts[0] === $username) {
|
||||
return trim($parts[2]);
|
||||
@@ -21,37 +32,25 @@ function getUserRole($username) {
|
||||
}
|
||||
|
||||
/* --- OIDC Authentication Flow --- */
|
||||
if (isset($_GET['oidc'])) {
|
||||
// Read and decrypt OIDC configuration from JSON file.
|
||||
// Detect either ?oidc=… or a callback that only has ?code=
|
||||
$oidcAction = $_GET['oidc'] ?? null;
|
||||
if (!$oidcAction && isset($_GET['code'])) {
|
||||
$oidcAction = 'callback';
|
||||
}
|
||||
if ($oidcAction) {
|
||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||
if (file_exists($adminConfigFile)) {
|
||||
$encryptedContent = file_get_contents($adminConfigFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($decryptedContent === false) {
|
||||
// Log internal error and return a generic message.
|
||||
error_log("Failed to decrypt admin configuration.");
|
||||
echo json_encode(['error' => 'Internal error.']);
|
||||
exit;
|
||||
}
|
||||
$adminConfig = json_decode($decryptedContent, true);
|
||||
if (isset($adminConfig['oidc'])) {
|
||||
$oidcConfig = $adminConfig['oidc'];
|
||||
$oidc_provider_url = !empty($oidcConfig['providerUrl']) ? $oidcConfig['providerUrl'] : 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = !empty($oidcConfig['clientId']) ? $oidcConfig['clientId'] : 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = !empty($oidcConfig['clientSecret']) ? $oidcConfig['clientSecret'] : 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = !empty($oidcConfig['redirectUri']) ? $oidcConfig['redirectUri'] : 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
} else {
|
||||
$oidc_provider_url = 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
}
|
||||
$enc = file_get_contents($adminConfigFile);
|
||||
$dec = decryptData($enc, $encryptionKey);
|
||||
$cfg = $dec !== false ? json_decode($dec, true) : [];
|
||||
} else {
|
||||
$oidc_provider_url = 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
$cfg = [];
|
||||
}
|
||||
$oidc_provider_url = $cfg['oidc']['providerUrl'] ?? 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = $cfg['oidc']['clientId'] ?? 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = $cfg['oidc']['clientSecret'] ?? 'YOUR_CLIENT_SECRET';
|
||||
// Use your production domain for redirect URI.
|
||||
$oidc_redirect_uri = $cfg['oidc']['redirectUri'] ?? 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
|
||||
$oidc = new Jumbojett\OpenIDConnectClient(
|
||||
$oidc_provider_url,
|
||||
@@ -60,31 +59,54 @@ if (isset($_GET['oidc'])) {
|
||||
);
|
||||
$oidc->setRedirectURL($oidc_redirect_uri);
|
||||
|
||||
if ($_GET['oidc'] === 'callback') {
|
||||
if ($oidcAction === 'callback') {
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
$username = $oidc->requestUserInfo('preferred_username');
|
||||
|
||||
// Check if this user has a TOTP secret.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$totp_secret = null;
|
||||
if (file_exists($usersFile)) {
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(":", trim($line));
|
||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||
$totp_secret = decryptData($parts[3], $encryptionKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($totp_secret) {
|
||||
// Hold pending login & prompt for TOTP.
|
||||
$_SESSION['pending_login_user'] = $username;
|
||||
$_SESSION['pending_login_secret'] = $totp_secret;
|
||||
header("Location: index.html?totp_required=1");
|
||||
exit();
|
||||
}
|
||||
|
||||
// No TOTP → finalize login.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
// Determine the user role from users.txt.
|
||||
$userRole = getUserRole($username);
|
||||
$_SESSION["isAdmin"] = ($userRole === "1");
|
||||
// *** Use loadUserPermissions() here instead of loadFolderPermission() ***
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($username);
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = (getUserRole($username) === "1");
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($username);
|
||||
|
||||
header("Location: index.html");
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
error_log("OIDC authentication error: " . $e->getMessage());
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Authentication failed."]);
|
||||
exit();
|
||||
}
|
||||
} else {
|
||||
// Initiate OIDC authentication.
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
error_log("OIDC initiation error: " . $e->getMessage());
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Authentication initiation failed."]);
|
||||
exit();
|
||||
}
|
||||
@@ -92,10 +114,9 @@ if (isset($_GET['oidc'])) {
|
||||
}
|
||||
|
||||
/* --- Fallback: Form-based Authentication --- */
|
||||
// (Form-based branch code remains unchanged. It calls loadUserPermissions() in its basic auth branch.)
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$maxAttempts = 5;
|
||||
$lockoutTime = 30 * 60;
|
||||
$lockoutTime = 30 * 60; // 30 minutes
|
||||
$attemptsFile = USERS_DIR . 'failed_logins.json';
|
||||
$failedLogFile = USERS_DIR . 'failed_login.log';
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
@@ -111,7 +132,7 @@ function loadFailedAttempts($file) {
|
||||
}
|
||||
|
||||
function saveFailedAttempts($file, $data) {
|
||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
|
||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
@@ -121,6 +142,7 @@ $failedAttempts = loadFailedAttempts($attemptsFile);
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$attemptData = $failedAttempts[$ip];
|
||||
if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) {
|
||||
http_response_code(429);
|
||||
echo json_encode(["error" => "Too many failed login attempts. Please try again later."]);
|
||||
exit();
|
||||
}
|
||||
@@ -137,11 +159,9 @@ function authenticate($username, $password) {
|
||||
if (count($parts) < 3) continue;
|
||||
if ($username === $parts[0] && password_verify($password, $parts[1])) {
|
||||
$result = ['role' => $parts[2]];
|
||||
if (isset($parts[3]) && !empty($parts[3])) {
|
||||
$result['totp_secret'] = decryptData($parts[3], $encryptionKey);
|
||||
} else {
|
||||
$result['totp_secret'] = null;
|
||||
}
|
||||
$result['totp_secret'] = (isset($parts[3]) && !empty($parts[3]))
|
||||
? decryptData($parts[3], $encryptionKey)
|
||||
: null;
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -154,11 +174,13 @@ $password = trim($data["password"] ?? "");
|
||||
$rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true;
|
||||
|
||||
if (!$username || !$password) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Username and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit();
|
||||
}
|
||||
@@ -166,14 +188,24 @@ if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
||||
$user = authenticate($username, $password);
|
||||
if ($user !== false) {
|
||||
if (!empty($user['totp_secret'])) {
|
||||
if (empty($data['totp_code'])) {
|
||||
echo json_encode([
|
||||
"totp_required" => true,
|
||||
"message" => "TOTP code required"
|
||||
]);
|
||||
exit();
|
||||
} else {
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
|
||||
// If TOTP code is missing or malformed, indicate that TOTP is required.
|
||||
if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) {
|
||||
// ← STORE pending user & secret so recovery can see it
|
||||
$_SESSION['pending_login_user'] = $username;
|
||||
$_SESSION['pending_login_secret'] = $user['totp_secret'];
|
||||
echo json_encode([
|
||||
"totp_required" => true,
|
||||
"message" => "TOTP code required"
|
||||
]);
|
||||
exit();
|
||||
} else {
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new GoogleChartsQrCodeProvider(), // QR code provider
|
||||
'FileRise', // issuer
|
||||
6, // number of digits
|
||||
30, // period in seconds
|
||||
Algorithm::Sha1 // Correct enum case name from your enum
|
||||
);
|
||||
$providedCode = trim($data['totp_code']);
|
||||
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
|
||||
echo json_encode(["error" => "Invalid TOTP code"]);
|
||||
@@ -210,10 +242,13 @@ if ($user !== false) {
|
||||
];
|
||||
$encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX);
|
||||
// Define $secure based on whether HTTPS is enabled
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"status" => "ok",
|
||||
"success" => "Login successful",
|
||||
"isAdmin" => $_SESSION["isAdmin"],
|
||||
"folderOnly"=> $_SESSION["folderOnly"],
|
||||
@@ -229,6 +264,7 @@ if ($user !== false) {
|
||||
saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
$logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL;
|
||||
file_put_contents($failedLogFile, $logLine, FILE_APPEND);
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Invalid credentials"]);
|
||||
}
|
||||
?>
|
||||
655
authModals.js
@@ -1,655 +0,0 @@
|
||||
import { showToast, toggleVisibility } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
|
||||
const version = "v1.0.5";
|
||||
const adminTitle = `Admin Panel <small style="font-size: 12px; color: gray;">${version}</small>`;
|
||||
let lastLoginData = null;
|
||||
|
||||
export function setLastLoginData(data) {
|
||||
lastLoginData = data;
|
||||
}
|
||||
|
||||
export function openTOTPLoginModal() {
|
||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const modalBg = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
const textColor = isDarkMode ? "#e0e0e0" : "#000";
|
||||
|
||||
if (!totpLoginModal) {
|
||||
totpLoginModal = document.createElement("div");
|
||||
totpLoginModal.id = "totpLoginModal";
|
||||
totpLoginModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3200;
|
||||
`;
|
||||
totpLoginModal.innerHTML = `
|
||||
<div style="background: ${modalBg}; padding: 20px; border-radius: 8px; text-align: center; position: relative; color: ${textColor};">
|
||||
<span id="closeTOTPLoginModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>Enter TOTP Code</h3>
|
||||
<input type="text" id="totpLoginInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(totpLoginModal);
|
||||
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
|
||||
totpLoginModal.style.display = "none";
|
||||
});
|
||||
const totpInput = document.getElementById("totpLoginInput");
|
||||
totpInput.focus();
|
||||
totpInput.addEventListener("input", function () {
|
||||
if (this.value.trim().length === 6 && lastLoginData) {
|
||||
lastLoginData.totp_code = this.value.trim();
|
||||
totpLoginModal.style.display = "none";
|
||||
if (typeof window.submitLogin === "function") {
|
||||
window.submitLogin(lastLoginData);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
totpLoginModal.style.display = "flex";
|
||||
const modalContent = totpLoginModal.firstElementChild;
|
||||
modalContent.style.background = modalBg;
|
||||
modalContent.style.color = textColor;
|
||||
}
|
||||
}
|
||||
|
||||
export function openUserPanel() {
|
||||
const username = localStorage.getItem("username") || "User";
|
||||
let userPanelModal = document.getElementById("userPanelModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||
transform: none;
|
||||
transition: none;
|
||||
`;
|
||||
if (!userPanelModal) {
|
||||
userPanelModal = document.createElement("div");
|
||||
userPanelModal.id = "userPanelModal";
|
||||
userPanelModal.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: 3000;
|
||||
`;
|
||||
userPanelModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>User Panel (${username})</h3>
|
||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">Change Password</button>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>TOTP Settings</legend>
|
||||
<div class="form-group">
|
||||
<label for="userTOTPEnabled">Enable TOTP:</label>
|
||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(userPanelModal);
|
||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
||||
userPanelModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
});
|
||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
||||
totpCheckbox.addEventListener("change", function () {
|
||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
||||
const enabled = this.checked;
|
||||
fetch("updateUserPanel.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_enabled: enabled })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) {
|
||||
showToast("Error updating TOTP setting: " + result.error);
|
||||
} else if (enabled) {
|
||||
openTOTPModal();
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error updating TOTP setting."); });
|
||||
});
|
||||
} else {
|
||||
userPanelModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||
}
|
||||
userPanelModal.style.display = "flex";
|
||||
}
|
||||
|
||||
export function openTOTPModal() {
|
||||
let totpModal = document.getElementById("totpModal");
|
||||
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: 400px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
`;
|
||||
if (!totpModal) {
|
||||
totpModal = document.createElement("div");
|
||||
totpModal.id = "totpModal";
|
||||
totpModal.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: 3100;
|
||||
`;
|
||||
totpModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeTOTPModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>TOTP Setup</h3>
|
||||
<p>Scan this QR code with your authenticator app:</p>
|
||||
<img src="totp_setup.php?csrf=${encodeURIComponent(window.csrfToken)}" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
||||
<br/>
|
||||
<p>Enter the 6-digit code from your app to confirm setup:</p>
|
||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||
<br/><br/>
|
||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">Confirm</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(totpModal);
|
||||
// Bind the X button to call closeTOTPModal with disable=true
|
||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
||||
closeTOTPModal(true);
|
||||
});
|
||||
|
||||
// Add event listener for TOTP confirmation
|
||||
document.getElementById("confirmTOTPBtn").addEventListener("click", function () {
|
||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||
if (code.length !== 6) {
|
||||
showToast("Please enter a valid 6-digit code.");
|
||||
return;
|
||||
}
|
||||
// Call the endpoint to verify the TOTP code
|
||||
fetch("totp_verify.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_code: code })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast("TOTP successfully enabled.");
|
||||
// On success, close the modal without disabling
|
||||
closeTOTPModal(false);
|
||||
} else {
|
||||
showToast("TOTP verification failed: " + (result.error || "Invalid code."));
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error verifying TOTP code."); });
|
||||
});
|
||||
} else {
|
||||
totpModal.style.display = "flex";
|
||||
totpModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = totpModal.querySelector(".modal-content");
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
}
|
||||
}
|
||||
|
||||
// Updated closeTOTPModal function with a disable parameter
|
||||
export function closeTOTPModal(disable = true) {
|
||||
const totpModal = document.getElementById("totpModal");
|
||||
if (totpModal) totpModal.style.display = "none";
|
||||
|
||||
if (disable) {
|
||||
// Uncheck the Enable TOTP checkbox
|
||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||
if (totpCheckbox) {
|
||||
totpCheckbox.checked = false;
|
||||
localStorage.setItem("userTOTPEnabled", "false");
|
||||
}
|
||||
// Call endpoint to remove the TOTP secret from the user's record
|
||||
fetch("totp_disable.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) {
|
||||
showToast("Error disabling TOTP setting: " + result.error);
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error disabling TOTP setting."); });
|
||||
}
|
||||
}
|
||||
|
||||
export function openAdminPanel() {
|
||||
fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
|
||||
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||
`;
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
|
||||
if (!adminModal) {
|
||||
adminModal = document.createElement("div");
|
||||
adminModal.id = "adminPanelModal";
|
||||
adminModal.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: 3000;
|
||||
`;
|
||||
// Added a version number next to "Admin Panel"
|
||||
adminModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeAdminPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>
|
||||
<h3>${adminTitle}</h3>
|
||||
</h3>
|
||||
<form id="adminPanelForm">
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>User Management</legend>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="button" id="adminOpenAddUser" class="btn btn-success">Add User</button>
|
||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger">Remove User</button>
|
||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">User Permissions</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>OIDC Configuration</legend>
|
||||
<div class="form-group">
|
||||
<label for="oidcProviderUrl">OIDC Provider URL:</label>
|
||||
<input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig.providerUrl}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcClientId">OIDC Client ID:</label>
|
||||
<input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcClientSecret">OIDC Client Secret:</label>
|
||||
<input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcRedirectUri">OIDC Redirect URI:</label>
|
||||
<input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig.redirectUri}" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>Global TOTP Settings</legend>
|
||||
<div class="form-group">
|
||||
<label for="globalOtpauthUrl">Global OTPAuth URL:</label>
|
||||
<input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>Login Options</legend>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="disableFormLogin" />
|
||||
<label for="disableFormLogin">Disable Login Form</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="disableBasicAuth" />
|
||||
<label for="disableBasicAuth">Disable Basic HTTP Auth</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="disableOIDCLogin" />
|
||||
<label for="disableOIDCLogin">Disable OIDC Login</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="saveAdminSettings" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(adminModal);
|
||||
|
||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||
adminModal.addEventListener("click", (e) => {
|
||||
if (e.target === adminModal) closeAdminPanel();
|
||||
});
|
||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||
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);
|
||||
});
|
||||
// New event binding for the User Permissions button:
|
||||
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
|
||||
openUserPermissionsModal();
|
||||
});
|
||||
document.getElementById("saveAdminSettings").addEventListener("click", () => {
|
||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
||||
if (totalDisabled === 3) {
|
||||
showToast("At least one login method must remain enabled.");
|
||||
disableOIDCLoginCheckbox.checked = false;
|
||||
localStorage.setItem("disableOIDCLogin", "false");
|
||||
if (typeof window.updateLoginOptionsUI === "function") {
|
||||
window.updateLoginOptionsUI({
|
||||
disableFormLogin: disableFormLoginCheckbox.checked,
|
||||
disableBasicAuth: disableBasicAuthCheckbox.checked,
|
||||
disableOIDCLogin: disableOIDCLoginCheckbox.checked
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const newOIDCConfig = {
|
||||
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 disableFormLogin = disableFormLoginCheckbox.checked;
|
||||
const disableBasicAuth = disableBasicAuthCheckbox.checked;
|
||||
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
|
||||
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
|
||||
sendRequest("updateConfig.php", "POST", {
|
||||
oidc: newOIDCConfig,
|
||||
disableFormLogin,
|
||||
disableBasicAuth,
|
||||
disableOIDCLogin,
|
||||
globalOtpauthUrl
|
||||
}, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast("Settings updated successfully.");
|
||||
localStorage.setItem("disableFormLogin", disableFormLogin);
|
||||
localStorage.setItem("disableBasicAuth", disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
|
||||
if (typeof window.updateLoginOptionsUI === "function") {
|
||||
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
|
||||
}
|
||||
closeAdminPanel();
|
||||
} else {
|
||||
showToast("Error updating settings: " + (response.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
});
|
||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||
function enforceLoginOptionConstraint(changedCheckbox) {
|
||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
||||
if (changedCheckbox.checked && totalDisabled === 3) {
|
||||
showToast("At least one login method must remain enabled.");
|
||||
changedCheckbox.checked = false;
|
||||
}
|
||||
}
|
||||
disableFormLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
} else {
|
||||
adminModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = adminModal.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||
}
|
||||
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 || 'otpauth://totp/FileRise?issuer=FileRise';
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
adminModal.style.display = "flex";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) {
|
||||
adminModal.style.backgroundColor = "rgba(0,0,0,0.5)";
|
||||
const modalContent = adminModal.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.style.background = "#fff";
|
||||
modalContent.style.color = "#000";
|
||||
modalContent.style.border = "1px solid #ccc";
|
||||
}
|
||||
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 || 'otpauth://totp/FileRise?issuer=FileRise';
|
||||
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
|
||||
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
|
||||
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
|
||||
adminModal.style.display = "flex";
|
||||
} else {
|
||||
openAdminPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function closeAdminPanel() {
|
||||
const adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) adminModal.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" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>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">Cancel</button>
|
||||
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">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("updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast("User permissions updated successfully.");
|
||||
userPermissionsModal.style.display = "none";
|
||||
} else {
|
||||
showToast("Error updating permissions: " + (response.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("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("getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
// Then, fetch the list of users.
|
||||
return fetch("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>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 localStorage defaults.
|
||||
const defaultPerm = {
|
||||
folderOnly: localStorage.getItem("folderOnly") === "true",
|
||||
readOnly: localStorage.getItem("readOnly") === "true",
|
||||
disableUpload: localStorage.getItem("disableUpload") === "true"
|
||||
};
|
||||
const userPerm = (permissionsData && typeof permissionsData === "object" && permissionsData[user.username]) || 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" : ""} />
|
||||
User Folder Only
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
|
||||
Read Only
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
|
||||
Disable Upload
|
||||
</label>
|
||||
</div>
|
||||
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
|
||||
`;
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
listContainer.innerHTML = "<p>Error loading users.</p>";
|
||||
});
|
||||
}
|
||||
11
composer.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "error311/filerise",
|
||||
"description": "FileRise – A lightweight self-hosted file manager",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"jumbojett/openid-connect-php": "^1.0.0",
|
||||
"phpseclib/phpseclib": "~3.0.7",
|
||||
"robthree/twofactorauth": "^3.0",
|
||||
"endroid/qr-code": "^5.0"
|
||||
}
|
||||
}
|
||||
545
composer.lock
generated
Normal file
@@ -0,0 +1,545 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6b70aec0c1830ebb2b8f9bb625b04a22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "f9cc1f52b5a463062251d666761178dbdb6b544f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f",
|
||||
"reference": "f9cc1f52b5a463062251d666761178dbdb6b544f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || 11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1"
|
||||
},
|
||||
"time": "2024-10-01T13:55:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
|
||||
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.6"
|
||||
},
|
||||
"time": "2024-08-09T14:30:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "endroid/qr-code",
|
||||
"version": "5.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/endroid/qr-code.git",
|
||||
"reference": "393fec6c4cbdc1bd65570ac9d245704428010122"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/endroid/qr-code/zipball/393fec6c4cbdc1bd65570ac9d245704428010122",
|
||||
"reference": "393fec6c4cbdc1bd65570ac9d245704428010122",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"endroid/quality": "dev-main",
|
||||
"ext-gd": "*",
|
||||
"khanamiryan/qrcode-detector-decoder": "^2.0.2",
|
||||
"setasign/fpdf": "^1.8.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Enables you to write PNG images",
|
||||
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
|
||||
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
|
||||
"setasign/fpdf": "Enables you to use the PDF writer"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Endroid\\QrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeroen van den Enden",
|
||||
"email": "info@endroid.nl"
|
||||
}
|
||||
],
|
||||
"description": "Endroid QR Code",
|
||||
"homepage": "https://github.com/endroid/qr-code",
|
||||
"keywords": [
|
||||
"code",
|
||||
"endroid",
|
||||
"php",
|
||||
"qr",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/endroid/qr-code/issues",
|
||||
"source": "https://github.com/endroid/qr-code/tree/5.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/endroid",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-08T08:52:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jumbojett/openid-connect-php",
|
||||
"version": "v1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jumbojett/OpenID-Connect-PHP.git",
|
||||
"reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jumbojett/OpenID-Connect-PHP/zipball/f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51",
|
||||
"reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"php": ">=7.0",
|
||||
"phpseclib/phpseclib": "^3.0.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "<10",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"description": "Bare-bones OpenID Connect client",
|
||||
"support": {
|
||||
"issues": "https://github.com/jumbojett/OpenID-Connect-PHP/issues",
|
||||
"source": "https://github.com/jumbojett/OpenID-Connect-PHP/tree/v1.0.2"
|
||||
},
|
||||
"time": "2024-09-13T07:08:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
|
||||
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9",
|
||||
"vimeo/psalm": "^4|^5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ParagonIE\\ConstantTime\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com",
|
||||
"role": "Maintainer"
|
||||
},
|
||||
{
|
||||
"name": "Steve 'Sc00bz' Thomas",
|
||||
"email": "steve@tobtu.com",
|
||||
"homepage": "https://www.tobtu.com",
|
||||
"role": "Original Developer"
|
||||
}
|
||||
],
|
||||
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
||||
"keywords": [
|
||||
"base16",
|
||||
"base32",
|
||||
"base32_decode",
|
||||
"base32_encode",
|
||||
"base64",
|
||||
"base64_decode",
|
||||
"base64_encode",
|
||||
"bin2hex",
|
||||
"encoding",
|
||||
"hex",
|
||||
"hex2bin",
|
||||
"rfc4648"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
||||
"source": "https://github.com/paragonie/constant_time_encoding"
|
||||
},
|
||||
"time": "2024-05-08T12:36:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/random_compat",
|
||||
"version": "v9.99.100",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/random_compat.git",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">= 7"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "4.*|5.*",
|
||||
"vimeo/psalm": "^1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
||||
},
|
||||
"type": "library",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
||||
"keywords": [
|
||||
"csprng",
|
||||
"polyfill",
|
||||
"pseudorandom",
|
||||
"random"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/random_compat/issues",
|
||||
"source": "https://github.com/paragonie/random_compat"
|
||||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpseclib/phpseclib",
|
||||
"version": "3.0.43",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||
"reference": "709ec107af3cb2f385b9617be72af8cf62441d02"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02",
|
||||
"reference": "709ec107af3cb2f385b9617be72af8cf62441d02",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/constant_time_encoding": "^1|^2|^3",
|
||||
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
|
||||
"php": ">=5.6.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
|
||||
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
|
||||
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
|
||||
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
|
||||
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"phpseclib/bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"phpseclib3\\": "phpseclib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jim Wigginton",
|
||||
"email": "terrafrost@php.net",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Patrick Monnerat",
|
||||
"email": "pm@datasphere.ch",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Andreas Fischer",
|
||||
"email": "bantu@phpbb.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Hans-Jürgen Petrich",
|
||||
"email": "petrich@tronic-media.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "graham@alt-three.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
|
||||
"homepage": "http://phpseclib.sourceforge.net",
|
||||
"keywords": [
|
||||
"BigInteger",
|
||||
"aes",
|
||||
"asn.1",
|
||||
"asn1",
|
||||
"blowfish",
|
||||
"crypto",
|
||||
"cryptography",
|
||||
"encryption",
|
||||
"rsa",
|
||||
"security",
|
||||
"sftp",
|
||||
"signature",
|
||||
"signing",
|
||||
"ssh",
|
||||
"twofish",
|
||||
"x.509",
|
||||
"x509"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.43"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/terrafrost",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpseclib",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-12-14T21:12:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "robthree/twofactorauth",
|
||||
"version": "v3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/RobThree/TwoFactorAuth.git",
|
||||
"reference": "6d70f9ca8e25568f163a7b3b3ff77bd8ea743978"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/6d70f9ca8e25568f163a7b3b3ff77bd8ea743978",
|
||||
"reference": "6d70f9ca8e25568f163a7b3b3ff77bd8ea743978",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.13",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"suggest": {
|
||||
"bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider",
|
||||
"endroid/qr-code": "Needed for EndroidQrCodeProvider"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"RobThree\\Auth\\": "lib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Rob Janssen",
|
||||
"homepage": "http://robiii.me",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Nicolas CARPi",
|
||||
"homepage": "https://github.com/NicolasCARPi",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Will Power",
|
||||
"homepage": "https://github.com/willpower232",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Two Factor Authentication",
|
||||
"homepage": "https://github.com/RobThree/TwoFactorAuth",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"MFA",
|
||||
"Multi Factor Authentication",
|
||||
"Two Factor Authentication",
|
||||
"authenticator",
|
||||
"authy",
|
||||
"php",
|
||||
"tfa"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/RobThree/TwoFactorAuth/issues",
|
||||
"source": "https://github.com/RobThree/TwoFactorAuth"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/robiii",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/RobThree",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-24T15:14:25+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
45
config.php
@@ -1,5 +1,20 @@
|
||||
<?php
|
||||
// config.php
|
||||
header("Cache-Control: no-cache, must-revalidate");
|
||||
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
// Security headers
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
header("X-Frame-Options: SAMEORIGIN");
|
||||
header("Referrer-Policy: no-referrer-when-downgrade");
|
||||
// Only include Strict-Transport-Security if you are using HTTPS
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
|
||||
}
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
|
||||
// Define constants.
|
||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||
@@ -11,6 +26,10 @@ 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('PATTERN_FOLDER_NAME', '[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||
define('REGEX_FILE_NAME', '/^[\p{L}\p{N}\p{M}%\-\.\(\) _]+$/u');
|
||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
@@ -48,14 +67,17 @@ function decryptData($encryptedData, $encryptionKey)
|
||||
}
|
||||
|
||||
// Load encryption key from environment (override in production).
|
||||
$encryptionKey = getenv('PERSISTENT_TOKENS_KEY') ?: 'default_please_change_this_key';
|
||||
if (!$encryptionKey) {
|
||||
die('Encryption key for persistent tokens is not set.');
|
||||
$envKey = getenv('PERSISTENT_TOKENS_KEY');
|
||||
if ($envKey === false || $envKey === '') {
|
||||
$encryptionKey = 'default_please_change_this_key';
|
||||
error_log('WARNING: Using default encryption key. Please set PERSISTENT_TOKENS_KEY in your environment.');
|
||||
} else {
|
||||
$encryptionKey = $envKey;
|
||||
}
|
||||
|
||||
function loadUserPermissions($username)
|
||||
{
|
||||
global $encryptionKey; // Ensure $encryptionKey is available
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
|
||||
if (file_exists($permissionsFile)) {
|
||||
@@ -69,21 +91,12 @@ function loadUserPermissions($username)
|
||||
$permissions = json_decode($content, true);
|
||||
}
|
||||
|
||||
if (!is_array($permissions)) {
|
||||
} else {
|
||||
}
|
||||
|
||||
if (is_array($permissions) && array_key_exists($username, $permissions)) {
|
||||
$result = $permissions[$username];
|
||||
if (empty($result)) {
|
||||
return false;
|
||||
}
|
||||
return $result;
|
||||
} else {
|
||||
return !empty($result) ? $result : false;
|
||||
}
|
||||
} else {
|
||||
error_log("loadUserPermissions: Permissions file not found: $permissionsFile");
|
||||
}
|
||||
// Removed error_log() to prevent flooding logs when file is not found.
|
||||
return false; // Return false if no permissions found.
|
||||
}
|
||||
|
||||
@@ -132,7 +145,7 @@ if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token']))
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $tokenData["username"];
|
||||
// IMPORTANT: Set the folderOnly flag here for auto-login.
|
||||
$_SESSION["folderOnly"] = loadFolderPermission($tokenData["username"]);
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($tokenData["username"]);
|
||||
} else {
|
||||
unset($persistentTokens[$_COOKIE['remember_me_token']]);
|
||||
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
|
||||
@@ -18,9 +18,8 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -45,7 +44,7 @@ $destinationFolder = trim($data['destination']);
|
||||
$files = $data['files'];
|
||||
|
||||
// Validate folder names: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
$folderPattern = '/^[A-Za-z0-9_\- \/]+$/';
|
||||
$folderPattern = REGEX_FOLDER_NAME;
|
||||
if ($sourceFolder !== 'root' && !preg_match($folderPattern, $sourceFolder)) {
|
||||
echo json_encode(["error" => "Invalid source folder name."]);
|
||||
exit;
|
||||
@@ -105,7 +104,7 @@ $destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($dest
|
||||
$errors = [];
|
||||
|
||||
// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
foreach ($files as $fileName) {
|
||||
// Save the original name for metadata lookup.
|
||||
|
||||
@@ -23,9 +23,9 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -45,13 +45,13 @@ $folderName = trim($input['folderName']);
|
||||
$parent = isset($input['parent']) ? trim($input['parent']) : "";
|
||||
|
||||
// Basic sanitation: allow only letters, numbers, underscores, dashes, and spaces in folderName
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $folderName)) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optionally, sanitize the parent folder if needed.
|
||||
if ($parent && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $parent)) {
|
||||
if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid parent folder name.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
94
createFolderShareLink.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
// createFolderShareLink.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
// Get POST input.
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
if (!$input) {
|
||||
echo json_encode(["error" => "Invalid input."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to create shared folders."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
$folder = isset($input['folder']) ? trim($input['folder']) : "";
|
||||
$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60;
|
||||
$password = isset($input['password']) ? $input['password'] : "";
|
||||
$allowUpload = isset($input['allowUpload']) ? intval($input['allowUpload']) : 0;
|
||||
|
||||
// Validate folder name using regex.
|
||||
// Allow letters, numbers, underscores, hyphens, spaces and slashes.
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generate a secure token.
|
||||
try {
|
||||
$token = bin2hex(random_bytes(16)); // 32 hex characters.
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(["error" => "Could not generate token."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Calculate expiration time (Unix timestamp).
|
||||
$expires = time() + ($expirationMinutes * 60);
|
||||
|
||||
// Hash password if provided.
|
||||
$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
// Define the file to store share folder links.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
$shareLinks = [];
|
||||
if (file_exists($shareFile)) {
|
||||
$data = file_get_contents($shareFile);
|
||||
$shareLinks = json_decode($data, true);
|
||||
if (!is_array($shareLinks)) {
|
||||
$shareLinks = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired share links.
|
||||
$currentTime = time();
|
||||
foreach ($shareLinks as $key => $link) {
|
||||
if (isset($link["expires"]) && $link["expires"] < $currentTime) {
|
||||
unset($shareLinks[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new share record.
|
||||
$shareLinks[$token] = [
|
||||
"folder" => $folder,
|
||||
"expires" => $expires,
|
||||
"password" => $hashedPassword,
|
||||
"allowUpload" => $allowUpload
|
||||
];
|
||||
|
||||
// Save the share links.
|
||||
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT))) {
|
||||
// Determine base URL.
|
||||
if (defined('BASE_URL') && !empty(BASE_URL) && strpos(BASE_URL, 'yourwebsite') === false) {
|
||||
$baseUrl = rtrim(BASE_URL, '/');
|
||||
} else {
|
||||
// Prefer HTTP_HOST over SERVER_ADDR.
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
|
||||
// Use HTTP_HOST if set; fallback to gethostbyname if needed.
|
||||
$host = !empty($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : gethostbyname($_SERVER['SERVER_ADDR'] ?? 'localhost');
|
||||
$baseUrl = $protocol . "://" . $host;
|
||||
}
|
||||
// The share URL points to shareFolder.php.
|
||||
$link = $baseUrl . "/shareFolder.php?token=" . urlencode($token);
|
||||
echo json_encode(["token" => $token, "expires" => $expires, "link" => $link]);
|
||||
} else {
|
||||
echo json_encode(["error" => "Could not save share link."]);
|
||||
}
|
||||
?>
|
||||
@@ -9,13 +9,23 @@ if (!$input) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to create share files."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
$folder = isset($input['folder']) ? trim($input['folder']) : "";
|
||||
$file = isset($input['file']) ? basename($input['file']) : "";
|
||||
$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60;
|
||||
$password = isset($input['password']) ? $input['password'] : "";
|
||||
|
||||
// Validate folder using regex.
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ body {
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
.container-fluid {
|
||||
padding-left: 40px !important;
|
||||
padding-right: 40px !important;
|
||||
padding-left: 30px !important;
|
||||
padding-right: 30px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ body {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
height: 55px;
|
||||
padding: 10px 20px;
|
||||
background-color: #2196F3;
|
||||
transition: background-color 0.3s ease;
|
||||
@@ -82,28 +82,16 @@ body.dark-mode .header-container {
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
max-height: 70px;
|
||||
max-height: 50px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-logo svg {
|
||||
height: 70px;
|
||||
height: 50px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
background-color: #2196F3;
|
||||
transition: background-color 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.dark-mode header {
|
||||
background-color: #1f1f1f;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
@@ -650,12 +638,15 @@ body.dark-mode .editor-header {
|
||||
}
|
||||
|
||||
#uploadBtn {
|
||||
margin-top: 20px;
|
||||
font-size: 20px;
|
||||
padding: 10px 22px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body.d-flex.flex-column {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
#customChooseBtn {
|
||||
background-color: #9E9E9E;
|
||||
color: #fff;
|
||||
@@ -849,7 +840,8 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rename-btn .material-icons {
|
||||
.rename-btn .material-icons,
|
||||
#renameFolderBtn .material-icons {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
@@ -1068,7 +1060,7 @@ body.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
#customToast.show {
|
||||
opacity: 0.7;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
@@ -1580,39 +1572,6 @@ body.dark-mode .btn-secondary {
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
#toggleViewBtn {
|
||||
margin-bottom: 20px;
|
||||
margin-left: 14px;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#toggleViewBtn {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#toggleViewBtn {
|
||||
margin-left: 0 !important;
|
||||
margin-right: auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
#toggleViewBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body.dark-mode .btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: #fff;
|
||||
@@ -1725,21 +1684,6 @@ body.dark-mode .folder-help-icon {
|
||||
|
||||
}
|
||||
|
||||
body.dark-mode #searchIcon {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
body.dark-mode #searchInput {
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
|
||||
body.dark-mode .CodeMirror {
|
||||
background: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
@@ -1944,12 +1888,12 @@ body.dark-mode #folderContextMenu {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
min-height: 353px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
|
||||
#uploadFolderRow.highlight {
|
||||
min-height: 353px;
|
||||
min-height: 320px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -2023,20 +1967,17 @@ body.dark-mode .card {
|
||||
z-index: 6000 !important;
|
||||
}
|
||||
|
||||
/* Default (light mode) for admin panel content */
|
||||
.admin-panel-content {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Dark mode overrides for admin panel content */
|
||||
body.dark-mode .admin-panel-content {
|
||||
background: #2c2c2c; /* dark background */
|
||||
color: #e0e0e0; /* light text */
|
||||
background: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
/* Optionally, adjust input, label, etc. for dark mode */
|
||||
body.dark-mode .admin-panel-content input,
|
||||
body.dark-mode .admin-panel-content select,
|
||||
body.dark-mode .admin-panel-content textarea {
|
||||
@@ -2066,4 +2007,151 @@ body.dark-mode .admin-panel-content label {
|
||||
}
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.download-spinner {
|
||||
font-size: 48px;
|
||||
animation: spin 2s linear infinite;
|
||||
color: var(--download-spinner-color, #000);
|
||||
}
|
||||
|
||||
body:not(.dark-mode) {
|
||||
--download-spinner-color: #000;
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
--download-spinner-color: #fff;
|
||||
}
|
||||
|
||||
.rise-effect {
|
||||
transform: translateY(-20px);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-modal-btn,
|
||||
.collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 24px;
|
||||
color: #616161;
|
||||
border-radius: 50%;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-modal-btn:hover,
|
||||
.collapse-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toggle-modal-btn:focus,
|
||||
.collapse-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-drop-zone {
|
||||
width: 66px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.header-drop-zone.drag-active {
|
||||
border: 2px dashed #1565C0;
|
||||
background-color: #eef;
|
||||
background-color: transparent;
|
||||
transition: width 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body.dark-mode .header-drop-zone.drag-active {
|
||||
background-color: #333;
|
||||
border: 2px dashed #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-drop-zone.drag-active:empty::before {
|
||||
content: "Drop";
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Disable text selection on rows to prevent accidental copying when shift-clicking */
|
||||
#fileList tbody tr.clickable-row {
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* IE10+/Edge */
|
||||
user-select: none; /* Standard */
|
||||
}
|
||||
|
||||
#fileSummary {
|
||||
color: black;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
#fileSummary {
|
||||
float: none !important;
|
||||
margin: 0 auto !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark-mode #fileSummary {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#searchIcon {
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
body.dark-mode #searchIcon {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark-mode #searchInput {
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon .material-icons,
|
||||
#searchIcon .material-icons {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-icon:hover,
|
||||
.btn-icon:focus {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-icon .material-icons,
|
||||
body.dark-mode #searchIcon .material-icons {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-icon:hover,
|
||||
body.dark-mode .btn-icon:focus {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ if (!isset($data['files']) || !is_array($data['files'])) {
|
||||
$folder = isset($data['folder']) ? trim($data['folder']) : 'root';
|
||||
|
||||
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ $movedFiles = [];
|
||||
$errors = [];
|
||||
|
||||
// Define a safe file name pattern: allow letters, numbers, underscores, dashes, dots, and spaces.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
foreach ($data['files'] as $fileName) {
|
||||
$basename = basename(trim($fileName));
|
||||
|
||||
@@ -23,9 +23,9 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -50,7 +50,7 @@ if ($folderName === 'root') {
|
||||
}
|
||||
|
||||
// Allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
if (!preg_match('/^[A-Za-z0-9_\- \/]+$/', $folderName)) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ $deletedFiles = [];
|
||||
$errors = [];
|
||||
|
||||
// Define a safe file name pattern.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
foreach ($filesToDelete as $trashName) {
|
||||
$trashName = trim($trashName);
|
||||
|
||||
66
download.php
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// For GET requests (which download.php will use), we assume session authentication is enough.
|
||||
|
||||
// Check if the user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
@@ -16,44 +14,72 @@ $file = isset($_GET['file']) ? basename($_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
|
||||
// Validate file name (allowing letters, numbers, underscores, dashes, dots, and parentheses)
|
||||
if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $file)) {
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine the directory.
|
||||
if ($folder !== 'root') {
|
||||
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
||||
} else {
|
||||
$directory = UPLOAD_DIR;
|
||||
// Get the realpath of the upload directory.
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($uploadDirReal === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Server misconfiguration."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$filePath = $directory . $file;
|
||||
// Determine the directory.
|
||||
if ($folder === 'root') {
|
||||
$directory = $uploadDirReal;
|
||||
} else {
|
||||
// Prevent path traversal in folder parameter.
|
||||
if (strpos($folder, '..') !== false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$directoryPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
$directory = realpath($directoryPath);
|
||||
|
||||
// Ensure that the resolved directory exists and is within the allowed UPLOAD_DIR.
|
||||
if ($directory === false || strpos($directory, $uploadDirReal) !== 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder path."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
// Build the file path.
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$realFilePath = realpath($filePath);
|
||||
|
||||
// Validate that the real file path exists and is within the allowed directory.
|
||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Access forbidden."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!file_exists($realFilePath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "File not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve the file.
|
||||
$mimeType = mime_content_type($filePath);
|
||||
$mimeType = mime_content_type($realFilePath);
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// For images, serve inline; for other types, force download.
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) {
|
||||
header('Content-Disposition: inline; filename="' . basename($filePath) . '"');
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
header('Content-Length: ' . filesize($filePath));
|
||||
header('Content-Length: ' . filesize($realFilePath));
|
||||
|
||||
// Disable caching.
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
readfile($filePath);
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
?>
|
||||
85
downloadSharedFile.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// downloadSharedFile.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
// Retrieve and sanitize token and file name from GET.
|
||||
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
|
||||
$file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING);
|
||||
|
||||
if (empty($token) || empty($file)) {
|
||||
http_response_code(400);
|
||||
echo "Missing token or file parameter.";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load the share folder records.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
http_response_code(404);
|
||||
echo "Share link not found.";
|
||||
exit;
|
||||
}
|
||||
|
||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||
http_response_code(404);
|
||||
echo "Share link not found.";
|
||||
exit;
|
||||
}
|
||||
|
||||
$record = $shareLinks[$token];
|
||||
|
||||
// Check if the link has expired.
|
||||
if (time() > $record['expires']) {
|
||||
http_response_code(403);
|
||||
echo "This share link has expired.";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get the shared folder from the record.
|
||||
$folder = trim($record['folder'], "/\\ ");
|
||||
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
$realFolderPath = realpath($folderPath);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
|
||||
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
|
||||
http_response_code(404);
|
||||
echo "Shared folder not found.";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Sanitize the filename to prevent directory traversal.
|
||||
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) {
|
||||
http_response_code(400);
|
||||
echo "Invalid file name.";
|
||||
exit;
|
||||
}
|
||||
$file = basename($file);
|
||||
|
||||
// Build the full file path and verify it is inside the shared folder.
|
||||
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||||
$realFilePath = realpath($filePath);
|
||||
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
|
||||
http_response_code(404);
|
||||
echo "File not found.";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine MIME type.
|
||||
$mimeType = mime_content_type($realFilePath);
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// Set Content-Disposition header.
|
||||
// Inline if the file is an image; attachment for others.
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) {
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
|
||||
// Read and output the file.
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
?>
|
||||
@@ -38,7 +38,7 @@ $files = $data['files'];
|
||||
if ($folder !== "root") {
|
||||
$parts = explode('/', $folder);
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
@@ -76,7 +76,7 @@ if (empty($files)) {
|
||||
}
|
||||
|
||||
foreach ($files as $fileName) {
|
||||
if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $fileName)) {
|
||||
if (!preg_match(REGEX_FILE_NAME, $fileName)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Invalid file name: " . $fileName]);
|
||||
|
||||
364
dragAndDrop.js
@@ -1,364 +0,0 @@
|
||||
// dragAndDrop.js
|
||||
|
||||
// Moves cards into the sidebar based on the saved order in localStorage.
|
||||
export function loadSidebarOrder() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const orderStr = localStorage.getItem('sidebarOrder');
|
||||
if (orderStr) {
|
||||
const order = JSON.parse(orderStr);
|
||||
if (order.length > 0) {
|
||||
// Ensure main wrapper is visible.
|
||||
const mainWrapper = document.querySelector('.main-wrapper');
|
||||
if (mainWrapper) {
|
||||
mainWrapper.style.display = 'flex';
|
||||
}
|
||||
// For each saved ID, move the card into the sidebar.
|
||||
order.forEach(id => {
|
||||
const card = document.getElementById(id);
|
||||
if (card && card.parentNode.id !== 'sidebarDropArea') {
|
||||
sidebar.appendChild(card);
|
||||
// Animate vertical slide for sidebar card
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
updateSidebarVisibility();
|
||||
}
|
||||
|
||||
// Internal helper: update sidebar visibility based on its content.
|
||||
function updateSidebarVisibility() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
const cards = sidebar.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
if (cards.length > 0) {
|
||||
sidebar.classList.add('active');
|
||||
sidebar.style.display = 'block';
|
||||
} else {
|
||||
sidebar.classList.remove('active');
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
// Save the current order in localStorage.
|
||||
saveSidebarOrder();
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper: update top zone layout (center a card if one column is empty).
|
||||
function updateTopZoneLayout() {
|
||||
const leftCol = document.getElementById('leftCol');
|
||||
const rightCol = document.getElementById('rightCol');
|
||||
|
||||
const leftIsEmpty = !leftCol.querySelector('#uploadCard');
|
||||
const rightIsEmpty = !rightCol.querySelector('#folderManagementCard');
|
||||
|
||||
if (leftIsEmpty && !rightIsEmpty) {
|
||||
leftCol.style.display = 'none';
|
||||
rightCol.style.margin = '0 auto';
|
||||
} else if (rightIsEmpty && !leftIsEmpty) {
|
||||
rightCol.style.display = 'none';
|
||||
leftCol.style.margin = '0 auto';
|
||||
} else {
|
||||
leftCol.style.display = '';
|
||||
rightCol.style.display = '';
|
||||
leftCol.style.margin = '';
|
||||
rightCol.style.margin = '';
|
||||
}
|
||||
}
|
||||
|
||||
// When a card is being dragged, if the top drop zone is empty, set its min-height.
|
||||
function addTopZoneHighlight() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (topZone) {
|
||||
topZone.classList.add('highlight');
|
||||
if (topZone.querySelectorAll('#uploadCard, #folderManagementCard').length === 0) {
|
||||
topZone.style.minHeight = '375px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the drag ends, remove the extra min-height.
|
||||
function removeTopZoneHighlight() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (topZone) {
|
||||
topZone.classList.remove('highlight');
|
||||
topZone.style.minHeight = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical slide/fade animation helper.
|
||||
function animateVerticalSlide(card) {
|
||||
card.style.transform = 'translateY(30px)';
|
||||
card.style.opacity = '0';
|
||||
// Force reflow.
|
||||
card.offsetWidth;
|
||||
requestAnimationFrame(() => {
|
||||
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
|
||||
card.style.transform = 'translateY(0)';
|
||||
card.style.opacity = '1';
|
||||
});
|
||||
setTimeout(() => {
|
||||
card.style.transition = '';
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
}, 310);
|
||||
}
|
||||
|
||||
// Internal helper: insert card into sidebar at a proper position based on event.clientY.
|
||||
function insertCardInSidebar(card, event) {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const existingCards = Array.from(sidebar.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
let inserted = false;
|
||||
for (const currentCard of existingCards) {
|
||||
const rect = currentCard.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (event.clientY < midY) {
|
||||
sidebar.insertBefore(card, currentCard);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inserted) {
|
||||
sidebar.appendChild(card);
|
||||
}
|
||||
// Ensure card fills the sidebar.
|
||||
card.style.width = '100%';
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
|
||||
// Internal helper: save the current sidebar card order to localStorage.
|
||||
function saveSidebarOrder() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
const cards = sidebar.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
const order = Array.from(cards).map(card => card.id);
|
||||
localStorage.setItem('sidebarOrder', JSON.stringify(order));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: move cards from sidebar back to the top drop area when on small screens.
|
||||
function moveSidebarCardsToTop() {
|
||||
if (window.innerWidth < 1205) {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const cards = Array.from(sidebar.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
cards.forEach(card => {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
});
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for window resize to automatically move sidebar cards back to top on small screens.
|
||||
window.addEventListener('resize', function () {
|
||||
if (window.innerWidth < 1205) {
|
||||
moveSidebarCardsToTop();
|
||||
}
|
||||
});
|
||||
|
||||
// This function ensures the top drop zone (#uploadFolderRow) has a stable width when empty.
|
||||
function ensureTopZonePlaceholder() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (!topZone) return;
|
||||
if (topZone.querySelectorAll('#uploadCard, #folderManagementCard').length === 0) {
|
||||
let placeholder = topZone.querySelector('.placeholder');
|
||||
if (!placeholder) {
|
||||
placeholder = document.createElement('div');
|
||||
placeholder.className = 'placeholder';
|
||||
placeholder.style.visibility = 'hidden';
|
||||
placeholder.style.display = 'block';
|
||||
placeholder.style.width = '100%';
|
||||
placeholder.style.height = '375px';
|
||||
topZone.appendChild(placeholder);
|
||||
}
|
||||
} else {
|
||||
const placeholder = topZone.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// This sets up all drag-and-drop event listeners for cards.
|
||||
export function initDragAndDrop() {
|
||||
function run() {
|
||||
const draggableCards = document.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
draggableCards.forEach(card => {
|
||||
if (!card.dataset.originalContainerId) {
|
||||
card.dataset.originalContainerId = card.parentNode.id;
|
||||
}
|
||||
const header = card.querySelector('.card-header');
|
||||
if (header) {
|
||||
header.classList.add('drag-header');
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let dragTimer = null;
|
||||
let offsetX = 0, offsetY = 0;
|
||||
let initialLeft, initialTop;
|
||||
|
||||
if (header) {
|
||||
header.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
const card = this.closest('.card');
|
||||
// Capture the card's initial bounding rectangle once.
|
||||
const initialRect = card.getBoundingClientRect();
|
||||
const originX = ((e.clientX - initialRect.left) / initialRect.width) * 100;
|
||||
const originY = ((e.clientY - initialRect.top) / initialRect.height) * 100;
|
||||
card.style.transformOrigin = `${originX}% ${originY}%`;
|
||||
|
||||
// Store the initial rect so we use it later.
|
||||
dragTimer = setTimeout(() => {
|
||||
isDragging = true;
|
||||
card.classList.add('dragging');
|
||||
card.style.pointerEvents = 'none';
|
||||
addTopZoneHighlight();
|
||||
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('active');
|
||||
sidebar.style.display = 'block';
|
||||
sidebar.classList.add('highlight');
|
||||
sidebar.style.height = '800px';
|
||||
}
|
||||
|
||||
// Use the stored initialRect rather than recalculating.
|
||||
initialLeft = initialRect.left + window.pageXOffset;
|
||||
initialTop = initialRect.top + window.pageYOffset;
|
||||
offsetX = e.pageX - initialLeft;
|
||||
offsetY = e.pageY - initialTop;
|
||||
|
||||
// Append card to body and fix its dimensions to prevent shrinking.
|
||||
document.body.appendChild(card);
|
||||
card.style.position = 'absolute';
|
||||
card.style.left = initialLeft + 'px';
|
||||
card.style.top = initialTop + 'px';
|
||||
card.style.width = initialRect.width + 'px';
|
||||
card.style.height = initialRect.height + 'px';
|
||||
card.style.minWidth = initialRect.width + 'px';
|
||||
card.style.flexShrink = '0';
|
||||
card.style.zIndex = '10000';
|
||||
}, 500);
|
||||
});
|
||||
header.addEventListener('mouseup', function () {
|
||||
clearTimeout(dragTimer);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (isDragging) {
|
||||
card.style.left = (e.pageX - offsetX) + 'px';
|
||||
card.style.top = (e.pageY - offsetY) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function (e) {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
card.style.pointerEvents = '';
|
||||
card.classList.remove('dragging');
|
||||
removeTopZoneHighlight();
|
||||
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('highlight');
|
||||
sidebar.style.height = '';
|
||||
}
|
||||
|
||||
let droppedInSidebar = false;
|
||||
let droppedInTop = false;
|
||||
|
||||
// Check if dropped in sidebar drop zone.
|
||||
const sidebarElem = document.getElementById('sidebarDropArea');
|
||||
if (sidebarElem) {
|
||||
const rect = sidebarElem.getBoundingClientRect();
|
||||
const dropZoneBottom = rect.top + 800; // Virtual drop zone height.
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= dropZoneBottom
|
||||
) {
|
||||
insertCardInSidebar(card, e);
|
||||
droppedInSidebar = true;
|
||||
}
|
||||
}
|
||||
// If not dropped in sidebar, check the top drop zone.
|
||||
const topRow = document.getElementById('uploadFolderRow');
|
||||
if (!droppedInSidebar && topRow) {
|
||||
const rect = topRow.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
let container;
|
||||
if (card.id === 'uploadCard') {
|
||||
container = document.getElementById('leftCol');
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
container = document.getElementById('rightCol');
|
||||
}
|
||||
if (container) {
|
||||
ensureTopZonePlaceholder();
|
||||
updateTopZoneLayout();
|
||||
container.appendChild(card);
|
||||
droppedInTop = true;
|
||||
// Use computed style to determine container's width.
|
||||
const containerWidth = parseFloat(window.getComputedStyle(container).width);
|
||||
card.style.width = "363px";
|
||||
// Animate the card sliding in.
|
||||
animateVerticalSlide(card);
|
||||
// After animation completes, clear the inline width.
|
||||
setTimeout(() => {
|
||||
card.style.removeProperty('width');
|
||||
}, 210);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If dropped in neither area, return card to its original container.
|
||||
if (!droppedInSidebar && !droppedInTop) {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
card.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear inline styles from dragging.
|
||||
[
|
||||
'position',
|
||||
'left',
|
||||
'top',
|
||||
'z-index',
|
||||
'height',
|
||||
'min-width',
|
||||
'flex-shrink',
|
||||
'transition',
|
||||
'transform',
|
||||
'opacity'
|
||||
].forEach(prop => card.style.removeProperty(prop));
|
||||
|
||||
// For sidebar drops, force width to 100%.
|
||||
if (droppedInSidebar) {
|
||||
card.style.width = '100%';
|
||||
}
|
||||
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -50,7 +50,7 @@ if (empty($files)) {
|
||||
if ($folder !== "root") {
|
||||
$parts = explode('/', $folder);
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
@@ -92,7 +92,7 @@ $destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($dest
|
||||
$errors = [];
|
||||
$allSuccess = true;
|
||||
$extractedFiles = array(); // Array to collect names of extracted files
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
// ---------- Process Each File ----------
|
||||
foreach ($files as $zipFileName) {
|
||||
|
||||
1610
fileManager.js
@@ -11,14 +11,24 @@ if (file_exists($configFile)) {
|
||||
echo json_encode(['error' => 'Failed to decrypt configuration.']);
|
||||
exit;
|
||||
}
|
||||
// Decode the configuration and ensure globalOtpauthUrl is set
|
||||
// Decode the configuration and ensure required fields are set
|
||||
$config = json_decode($decryptedContent, true);
|
||||
|
||||
// Ensure globalOtpauthUrl is set
|
||||
if (!isset($config['globalOtpauthUrl'])) {
|
||||
$config['globalOtpauthUrl'] = "";
|
||||
}
|
||||
|
||||
// NEW: Ensure header_title is set.
|
||||
if (!isset($config['header_title']) || empty($config['header_title'])) {
|
||||
$config['header_title'] = "FileRise"; // default value
|
||||
}
|
||||
|
||||
echo json_encode($config);
|
||||
} else {
|
||||
// If no config file exists, provide defaults
|
||||
echo json_encode([
|
||||
'header_title' => "FileRise",
|
||||
'oidc' => [
|
||||
'providerUrl' => 'https://your-oidc-provider.com',
|
||||
'clientId' => 'YOUR_CLIENT_ID',
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated
|
||||
@@ -14,7 +11,7 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
// Allow only safe characters in the folder parameter (letters, numbers, underscores, dashes, spaces, and forward slashes).
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
@@ -28,11 +25,6 @@ if ($folder !== 'root') {
|
||||
|
||||
/**
|
||||
* Helper: Generate the metadata file path for a given folder.
|
||||
* For "root", returns "root_metadata.json". Otherwise, replaces slashes,
|
||||
* backslashes, and spaces with dashes and appends "_metadata.json".
|
||||
*
|
||||
* @param string $folder The folder's relative path.
|
||||
* @return string The full path to the folder's metadata file.
|
||||
*/
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
@@ -53,7 +45,7 @@ $files = array_values(array_diff(scandir($directory), array('.', '..')));
|
||||
$fileList = [];
|
||||
|
||||
// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Skip hidden files (those that begin with a dot)
|
||||
@@ -72,7 +64,6 @@ foreach ($files as $file) {
|
||||
|
||||
// Since metadata is stored per folder, the key is simply the file name.
|
||||
$metaKey = $file;
|
||||
|
||||
$fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown";
|
||||
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
||||
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
||||
@@ -88,7 +79,8 @@ foreach ($files as $file) {
|
||||
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
||||
}
|
||||
|
||||
$fileList[] = [
|
||||
// Build the basic file entry.
|
||||
$fileEntry = [
|
||||
'name' => $file,
|
||||
'modified' => $fileDateModified,
|
||||
'uploaded' => $fileUploadedDate,
|
||||
@@ -96,6 +88,14 @@ foreach ($files as $file) {
|
||||
'uploader' => $fileUploader,
|
||||
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
|
||||
];
|
||||
|
||||
// Add file content for text-based files.
|
||||
if (preg_match('/\.(txt|html|htm|md|js|css|json|xml|php|py|ini|conf|log)$/i', $file)) {
|
||||
$content = file_get_contents($filePath);
|
||||
$fileEntry['content'] = $content;
|
||||
}
|
||||
|
||||
$fileList[] = $fileEntry;
|
||||
}
|
||||
|
||||
// Load global tags from createdTags.json.
|
||||
|
||||
40
getFileTag.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// getFileTag.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
// Set security and content headers
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$metadataPath = META_DIR . 'createdTags.json';
|
||||
|
||||
// Check if the metadata file exists and is readable
|
||||
if (!file_exists($metadataPath) || !is_readable($metadataPath)) {
|
||||
error_log('Metadata file does not exist or is not readable: ' . $metadataPath);
|
||||
http_response_code(200); // Return empty array with HTTP 200 so the client can handle it gracefully
|
||||
echo json_encode([]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = file_get_contents($metadataPath);
|
||||
if ($data === false) {
|
||||
error_log('Failed to read metadata file: ' . $metadataPath);
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Unable to read metadata file."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Decode the JSON data to check for validity
|
||||
$jsonData = json_decode($data, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log('Invalid JSON in metadata file: ' . $metadataPath . ' Error: ' . json_last_error_msg());
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Metadata file contains invalid JSON."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Output the re-encoded JSON to ensure well-formed output
|
||||
echo json_encode($jsonData);
|
||||
exit;
|
||||
@@ -20,7 +20,7 @@ function getSubfolders($dir, $relative = '') {
|
||||
$folders = [];
|
||||
$items = scandir($dir);
|
||||
// Allow letters, numbers, underscores, dashes, and spaces in folder names.
|
||||
$safeFolderNamePattern = '/^[A-Za-z0-9_\- ]+$/';
|
||||
$safeFolderNamePattern = REGEX_FOLDER_NAME;
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
if (!preg_match($safeFolderNamePattern, $item)) {
|
||||
|
||||
@@ -17,7 +17,7 @@ if (file_exists($usersFile)) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 3) {
|
||||
// Validate username format:
|
||||
if (preg_match('/^[A-Za-z0-9_\- ]+$/', $parts[0])) {
|
||||
if (preg_match(REGEX_USER, $parts[0])) {
|
||||
$users[] = [
|
||||
"username" => $parts[0],
|
||||
"role" => trim($parts[2])
|
||||
|
||||
454
index.html
@@ -4,7 +4,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FileRise</title>
|
||||
<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="">
|
||||
@@ -16,126 +23,151 @@
|
||||
<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">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"></script>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
||||
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"
|
||||
integrity="sha384-xPpkMo5nDgD98fIcuRVYhxkZV6/9Y4L8s3p0J5c4MxgJkyKJ8BJr+xfRkq7kn6Tw"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"
|
||||
integrity="sha384-to8njsu2GAiXQnY/aLGzz0DIY/SFSeSDodtvSl869n2NmsBdHOTZNNqbEBPYh7Pa"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"
|
||||
integrity="sha384-kmQrbJf09Uo1WRLMDVGoVG3nM6F48frIhcj7f3FDUjeRzsiHwyBWDjMUIttnIeAf"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"
|
||||
integrity="sha384-EXTg7rRfdTPZWoKVCslusAAev2TYw76fm+Wox718iEtFQ+gdAdAc5Z/ndLHSo4mq"
|
||||
crossorigin="anonymous"></script>
|
||||
<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>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="header-container">
|
||||
<div class="header-left">
|
||||
<div class="header-logo">
|
||||
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" xml:space="preserve">
|
||||
<defs>
|
||||
<!-- Gradient for the cabinet body -->
|
||||
<linearGradient id="cabinetGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<!-- Drop shadow filter with animated attributes for a lifting effect -->
|
||||
<filter id="shadowFilter" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow id="dropShadow" dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.2">
|
||||
<!-- Animate the vertical offset: from 2 to 1 (as it rises), hold, then back to 2 -->
|
||||
<animate attributeName="dy" values="2;1;1;2" keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
<!-- Animate the blur similarly: from 2 to 1.5 then back to 2 -->
|
||||
<animate attributeName="stdDeviation" values="2;1.5;1.5;2" keyTimes="0;0.2;0.8;1" dur="5s"
|
||||
fill="freeze" />
|
||||
</feDropShadow>
|
||||
</filter>
|
||||
</defs>
|
||||
<style type="text/css">
|
||||
/* Cabinet with gradient, white outline, and drop shadow */
|
||||
.cabinet {
|
||||
fill: url(#cabinetGradient);
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
stroke: #1565C0;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.handle {
|
||||
fill: #1565C0;
|
||||
}
|
||||
</style>
|
||||
<!-- Group that will animate upward and then back down once -->
|
||||
<g id="cabinetGroup">
|
||||
<!-- Cabinet Body with rounded corners, white outline, and drop shadow -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="6" ry="6" class="cabinet" filter="url(#shadowFilter)" />
|
||||
<!-- Divider lines for drawers -->
|
||||
<line x1="5" y1="22" x2="59" y2="22" class="divider" />
|
||||
<line x1="5" y1="34" x2="59" y2="34" class="divider" />
|
||||
<!-- Drawers with Handles -->
|
||||
<rect x="8" y="24" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="27" r="1.5" class="handle" />
|
||||
<rect x="8" y="36" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="39" r="1.5" class="handle" />
|
||||
<rect x="8" y="48" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="51" r="1.5" class="handle" />
|
||||
<!-- Additional detail: a small top handle on the cabinet door -->
|
||||
<rect x="28" y="10" width="8" height="4" rx="1" ry="1" fill="#1565C0" />
|
||||
<!-- Animate transform: rises by 2 pixels over 1s, holds for 3s, then falls over 1s (total 5s) -->
|
||||
<animateTransform attributeName="transform" type="translate" values="0 0; 0 -2; 0 -2; 0 0"
|
||||
keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="index.html">
|
||||
<div class="header-logo">
|
||||
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" xml:space="preserve">
|
||||
<defs>
|
||||
<!-- Gradient for the cabinet body -->
|
||||
<linearGradient id="cabinetGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<!-- Drop shadow filter with animated attributes for a lifting effect -->
|
||||
<filter id="shadowFilter" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow id="dropShadow" dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.2">
|
||||
<!-- Animate the vertical offset: from 2 to 1 (as it rises), hold, then back to 2 -->
|
||||
<animate attributeName="dy" values="2;1;1;2" keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
<!-- Animate the blur similarly: from 2 to 1.5 then back to 2 -->
|
||||
<animate attributeName="stdDeviation" values="2;1.5;1.5;2" keyTimes="0;0.2;0.8;1" dur="5s"
|
||||
fill="freeze" />
|
||||
</feDropShadow>
|
||||
</filter>
|
||||
</defs>
|
||||
<style type="text/css">
|
||||
/* Cabinet with gradient, white outline, and drop shadow */
|
||||
.cabinet {
|
||||
fill: url(#cabinetGradient);
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
stroke: #1565C0;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.handle {
|
||||
fill: #1565C0;
|
||||
}
|
||||
</style>
|
||||
<!-- Group that will animate upward and then back down once -->
|
||||
<g id="cabinetGroup">
|
||||
<!-- Cabinet Body with rounded corners, white outline, and drop shadow -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="6" ry="6" class="cabinet" filter="url(#shadowFilter)" />
|
||||
<!-- Divider lines for drawers -->
|
||||
<line x1="5" y1="22" x2="59" y2="22" class="divider" />
|
||||
<line x1="5" y1="34" x2="59" y2="34" class="divider" />
|
||||
<!-- Drawers with Handles -->
|
||||
<rect x="8" y="24" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="27" r="1.5" class="handle" />
|
||||
<rect x="8" y="36" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="39" r="1.5" class="handle" />
|
||||
<rect x="8" y="48" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="51" r="1.5" class="handle" />
|
||||
<!-- Additional detail: a small top handle on the cabinet door -->
|
||||
<rect x="28" y="10" width="8" height="4" rx="1" ry="1" fill="#1565C0" />
|
||||
<!-- Animate transform: rises by 2 pixels over 1s, holds for 3s, then falls over 1s (total 5s) -->
|
||||
<animateTransform attributeName="transform" type="translate" values="0 0; 0 -2; 0 -2; 0 0"
|
||||
keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-title">
|
||||
<h1>FileRise</h1>
|
||||
<h1 data-i18n-key="header_title">FileRise</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-buttons">
|
||||
<button id="logoutBtn" title="Logout">
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
</button>
|
||||
<button id="changePasswordBtn" title="Change Password" style="display: none;">
|
||||
<i class="material-icons">vpn_key</i>
|
||||
</button>
|
||||
<div id="restoreFilesModal" class="modal centered-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h4 class="custom-restore-header">
|
||||
<i class="material-icons orange-icon">restore_from_trash</i>
|
||||
<span>Restore or</span>
|
||||
<i class="material-icons red-icon">delete_for_ever</i>
|
||||
<span>Delete Trash Items</span>
|
||||
</h4>
|
||||
<div id="restoreFilesList"
|
||||
style="max-height:300px; overflow-y:auto; border:1px solid #ccc; padding:10px; margin-bottom:10px;">
|
||||
<!-- Trash items will be loaded here -->
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<button id="restoreSelectedBtn" class="btn btn-primary">Restore Selected</button>
|
||||
<button id="restoreAllBtn" class="btn btn-secondary">Restore All</button>
|
||||
<button id="deleteTrashSelectedBtn" class="btn btn-warning">Delete Selected</button>
|
||||
<button id="deleteAllBtn" class="btn btn-danger">Delete All</button>
|
||||
<button id="closeRestoreModal" class="btn btn-dark">Close</button>
|
||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
||||
<!-- 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>
|
||||
<div id="restoreFilesModal" class="modal centered-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h4 class="custom-restore-header">
|
||||
<i class="material-icons orange-icon">restore_from_trash</i>
|
||||
<span data-i18n-key="restore_text">Restore or</span>
|
||||
<i class="material-icons red-icon">delete_for_ever</i>
|
||||
<span data-i18n-key="delete_text">Delete Trash Items</span>
|
||||
</h4>
|
||||
<div id="restoreFilesList"
|
||||
style="max-height:300px; overflow-y:auto; border:1px solid #ccc; padding:10px; margin-bottom:10px;">
|
||||
<!-- Trash items will be loaded here -->
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
|
||||
Selected</button>
|
||||
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
|
||||
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
|
||||
Selected</button>
|
||||
<button id="deleteAllBtn" class="btn btn-danger" data-i18n-key="delete_all">Delete All</button>
|
||||
<button id="closeRestoreModal" class="btn btn-dark" data-i18n-key="close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="addUserBtn" data-i18n-title="add_user" style="display: none;">
|
||||
<i class="material-icons">person_add</i>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<button id="addUserBtn" title="Add User" style="display: none;">
|
||||
<i class="material-icons">person_add</i>
|
||||
</button>
|
||||
<button id="removeUserBtn" title="Remove User" style="display: none;">
|
||||
<i class="material-icons">person_remove</i>
|
||||
</button>
|
||||
<button id="darkModeToggle" class="dark-mode-toggle">Dark Mode</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></div>
|
||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||
|
||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||
<div class="main-wrapper">
|
||||
@@ -149,26 +181,27 @@
|
||||
<div class="col-12">
|
||||
<form id="authForm" method="post">
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">User:</label>
|
||||
<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">Password:</label>
|
||||
<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">Login</button>
|
||||
<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">Remember me</label>
|
||||
<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">Login with OIDC</button>
|
||||
<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="login_basic.php" class="btn btn-secondary">Use Basic HTTP Login</a>
|
||||
<a href="login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic HTTP
|
||||
Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,20 +214,22 @@
|
||||
<!-- Left Column (60% for Upload Card) -->
|
||||
<div id="leftCol" class="col-md-7" style="display: flex; justify-content: center;">
|
||||
<div id="uploadCard" class="card" style="width: 100%;">
|
||||
<div class="card-header">Upload Files/Folders</div>
|
||||
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
||||
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
|
||||
<div id="uploadDropArea"
|
||||
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
||||
<span>Drop files/folders here or click 'Choose Files'</span>
|
||||
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
|
||||
Files'</span>
|
||||
<br />
|
||||
<input type="file" id="file" name="file[]" class="form-control-file" multiple
|
||||
style="opacity:0; position:absolute; width:1px; height:1px;" />
|
||||
<button type="button" id="customChooseBtn">Choose Files</button>
|
||||
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto">Upload</button>
|
||||
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto"
|
||||
data-i18n-key="upload">Upload</button>
|
||||
<div id="uploadProgressContainer"></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -204,8 +239,8 @@
|
||||
<div id="rightCol" class="col-md-5" style="display: flex; justify-content: center;">
|
||||
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>Folder Navigation & Management</span>
|
||||
<button id="folderHelpBtn" class="btn btn-link" title="Folder Help"
|
||||
<span data-i18n-key="folder_navigation">Folder Navigation & Management</span>
|
||||
<button id="folderHelpBtn" class="btn btn-link" data-i18n-title="folder_help"
|
||||
style="padding: 0; border: none; background: none;">
|
||||
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
|
||||
</button>
|
||||
@@ -215,42 +250,57 @@
|
||||
<div id="folderTreeContainer"></div>
|
||||
</div>
|
||||
<div class="folder-actions mt-3">
|
||||
<button id="createFolderBtn" class="btn btn-primary">Create Folder</button>
|
||||
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
||||
<i class="material-icons">create_new_folder</i>
|
||||
</button>
|
||||
<div id="createFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Create Folder</h4>
|
||||
<input type="text" id="newFolderName" class="form-control" placeholder="Enter folder name"
|
||||
<h4 data-i18n-key="create_folder_title">Create Folder</h4>
|
||||
<input type="text" id="newFolderName" class="form-control"
|
||||
data-i18n-placeholder="enter_folder_name" placeholder="Enter folder name"
|
||||
style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelCreateFolder" class="btn btn-secondary">Cancel</button>
|
||||
<button id="submitCreateFolder" class="btn btn-primary">Create</button>
|
||||
<button id="cancelCreateFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitCreateFolder" class="btn btn-primary"
|
||||
data-i18n-key="create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="renameFolderBtn" class="btn btn-secondary ml-2" title="Rename Folder">
|
||||
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
<div id="renameFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Rename Folder</h4>
|
||||
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
||||
<input type="text" id="newRenameFolderName" class="form-control"
|
||||
placeholder="Enter new folder name" style="margin-top:10px;" />
|
||||
data-i18n-placeholder="rename_folder_placeholder" placeholder="Enter new folder name"
|
||||
style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelRenameFolder" class="btn btn-secondary">Cancel</button>
|
||||
<button id="submitRenameFolder" class="btn btn-primary">Rename</button>
|
||||
<button id="cancelRenameFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitRenameFolder" class="btn btn-primary"
|
||||
data-i18n-key="rename">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="deleteFolderBtn" class="btn btn-danger ml-2" title="Delete Folder">
|
||||
|
||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||
<i class="material-icons">share</i>
|
||||
</button>
|
||||
<button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder">
|
||||
<i class="material-icons">delete</i>
|
||||
</button>
|
||||
<div id="deleteFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Delete Folder</h4>
|
||||
<p id="deleteFolderMessage">Are you sure you want to delete this folder?</p>
|
||||
<h4 data-i18n-key="delete_folder_title">Delete Folder</h4>
|
||||
<p id="deleteFolderMessage" data-i18n-key="delete_folder_message">Are you sure you want to
|
||||
delete this folder?</p>
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelDeleteFolder" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmDeleteFolder" class="btn btn-danger">Delete</button>
|
||||
<button id="cancelDeleteFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDeleteFolder" class="btn btn-danger"
|
||||
data-i18n-key="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,10 +308,12 @@
|
||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
||||
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
|
||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
||||
<li>Click on a folder in the tree to view its files.</li>
|
||||
<li>Use [-] to collapse and [+] to expand folders.</li>
|
||||
<li>Select a folder and click "Create Folder" to add a subfolder.</li>
|
||||
<li>To rename or delete a folder, select it and then click the appropriate button.</li>
|
||||
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
|
||||
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
|
||||
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
|
||||
subfolder.</li>
|
||||
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
|
||||
the appropriate button.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,53 +325,62 @@
|
||||
|
||||
<!-- File List Section -->
|
||||
<div id="fileListContainer" style="display: none;">
|
||||
<h2 id="fileListTitle">Files in (Root)</h2>
|
||||
<h2 id="fileListTitle" data-i18n-key="file_list_title">Files in (Root)</h2>
|
||||
<div id="fileListActions" class="file-list-actions">
|
||||
<button id="deleteSelectedBtn" class="btn action-btn" style="display: none;">Delete Files</button>
|
||||
<button id="deleteSelectedBtn" class="btn action-btn" style="display: none;"
|
||||
data-i18n-key="delete_files">Delete Files</button>
|
||||
<div id="deleteFilesModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Delete Selected Files</h4>
|
||||
<p id="deleteFilesMessage">Are you sure you want to delete the selected files?</p>
|
||||
<h4 data-i18n-key="delete_selected_files_title">Delete Selected Files</h4>
|
||||
<p id="deleteFilesMessage" data-i18n-key="delete_files_message">Are you sure you want to delete the
|
||||
selected files?</p>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelDeleteFiles" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger">Delete</button>
|
||||
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="copySelectedBtn" class="btn action-btn" style="display: none;" disabled>Copy Files</button>
|
||||
<button id="copySelectedBtn" class="btn action-btn" style="display: none;" disabled
|
||||
data-i18n-key="copy_files">Copy Files</button>
|
||||
<div id="copyFilesModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Copy Selected Files</h4>
|
||||
<p id="copyFilesMessage">Select a target folder for copying the selected files:</p>
|
||||
<h4 data-i18n-key="copy_files_title">Copy Selected Files</h4>
|
||||
<p id="copyFilesMessage" data-i18n-key="copy_files_message">Select a target folder for copying the
|
||||
selected files:</p>
|
||||
<select id="copyTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelCopyFiles" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary">Copy</button>
|
||||
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="moveSelectedBtn" class="btn action-btn" style="display: none;" disabled>Move Files</button>
|
||||
<button id="moveSelectedBtn" class="btn action-btn" style="display: none;" disabled
|
||||
data-i18n-key="move_files">Move Files</button>
|
||||
<div id="moveFilesModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Move Selected Files</h4>
|
||||
<p id="moveFilesMessage">Select a target folder for moving the selected files:</p>
|
||||
<h4 data-i18n-key="move_files_title">Move Selected Files</h4>
|
||||
<p id="moveFilesMessage" data-i18n-key="move_files_message">Select a target folder for moving the
|
||||
selected files:</p>
|
||||
<select id="moveTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelMoveFiles" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary">Move</button>
|
||||
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled>Download ZIP</button>
|
||||
<button id="extractZipBtn" class="btn btn-sm btn-info" title="Extract Zip">Extract Zip</button>
|
||||
<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"
|
||||
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||
<div id="downloadZipModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h4>Download Selected Files as Zip</h4>
|
||||
<p>Enter a name for the zip file:</p>
|
||||
<input type="text" id="zipFileNameInput" class="form-control" placeholder="files.zip" />
|
||||
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
||||
<p data-i18n-key="download_zip_prompt">Enter a name for the zip file:</p>
|
||||
<input type="text" id="zipFileNameInput" class="form-control" data-i18n-placeholder="zip_placeholder"
|
||||
placeholder="files.zip" />
|
||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelDownloadZip" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary">Download</button>
|
||||
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,54 +391,82 @@
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</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="cursor:pointer;">×</span>
|
||||
<h3>Change Password</h3>
|
||||
<input type="password" id="oldPassword" placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="newPassword" placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="confirmPassword" placeholder="Confirm New Password"
|
||||
style="width:100%; margin: 5px 0;" />
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" style="width:100%;">Save</button>
|
||||
<span id="closeChangePasswordModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">×</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;" />
|
||||
<input type="password" id="newPassword" class="form-control" data-i18n-placeholder="new_password"
|
||||
placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
|
||||
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Create New User</h3>
|
||||
<label for="newUsername">Username:</label>
|
||||
<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">Password:</label>
|
||||
<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">Grant Admin Access</label>
|
||||
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button id="cancelUserBtn" class="btn btn-secondary">Cancel</button>
|
||||
<button id="saveUserBtn" class="btn btn-primary">Save User</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="removeUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Remove User</h3>
|
||||
<label for="removeUsernameSelect">Select a user to remove:</label>
|
||||
<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>
|
||||
<select id="removeUsernameSelect" class="form-control"></select>
|
||||
<div class="button-container">
|
||||
<button id="cancelRemoveUserBtn" class="btn btn-secondary">Cancel</button>
|
||||
<button id="deleteUserBtn" class="btn btn-danger">Delete User</button>
|
||||
<button id="cancelRemoveUserBtn" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="deleteUserBtn" class="btn btn-danger" data-i18n-key="delete_user">Delete User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="renameFileModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Rename File</h4>
|
||||
<input type="text" id="newFileName" class="form-control" placeholder="Enter new file name"
|
||||
style="margin-top:10px;" />
|
||||
<h4 data-i18n-key="rename_file_title">Rename File</h4>
|
||||
<input type="text" id="newFileName" class="form-control" data-i18n-placeholder="rename_file_placeholder"
|
||||
placeholder="Enter new file name" style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelRenameFile" class="btn btn-secondary">Cancel</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary">Rename</button>
|
||||
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,13 +474,12 @@
|
||||
<div class="modal-content">
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirmYesBtn" class="btn btn-primary">Yes</button>
|
||||
<button id="confirmNoBtn" class="btn btn-secondary">No</button>
|
||||
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes">Yes</button>
|
||||
<button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="main.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,15 +1,23 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, showToast, attachEnterKeyListener, showCustomConfirmModal } from './domUtils.js';
|
||||
import { loadFileList, renderFileTable, displayFilePreview, initFileActions } from './fileManager.js';
|
||||
import { t, applyTranslations } from './i18n.js';
|
||||
import {
|
||||
toggleVisibility,
|
||||
showToast as originalShowToast,
|
||||
attachEnterKeyListener,
|
||||
showCustomConfirmModal
|
||||
} from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { initFileActions } from './fileActions.js';
|
||||
import { renderFileTable } from './fileListView.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import {
|
||||
openTOTPLoginModal,
|
||||
openUserPanel,
|
||||
openTOTPModal,
|
||||
closeTOTPModal,
|
||||
openAdminPanel,
|
||||
import {
|
||||
openTOTPLoginModal as originalOpenTOTPLoginModal,
|
||||
openUserPanel,
|
||||
openTOTPModal,
|
||||
closeTOTPModal,
|
||||
openAdminPanel,
|
||||
closeAdminPanel,
|
||||
setLastLoginData
|
||||
setLastLoginData
|
||||
} from './authModals.js';
|
||||
|
||||
// Production OIDC configuration (override via API as needed)
|
||||
@@ -22,6 +30,44 @@ const currentOIDCConfig = {
|
||||
};
|
||||
window.currentOIDCConfig = currentOIDCConfig;
|
||||
|
||||
/* ----------------- TOTP & Toast Overrides ----------------- */
|
||||
// detect if we’re in a pending‑TOTP state
|
||||
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
||||
|
||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||
function showToast(msgKey) {
|
||||
const msg = t(msgKey);
|
||||
if (window.pendingTOTP && msgKey === "please_log_in_to_continue") {
|
||||
return;
|
||||
}
|
||||
originalShowToast(msg);
|
||||
}
|
||||
window.showToast = showToast;
|
||||
|
||||
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
|
||||
function openTOTPLoginModal() {
|
||||
originalOpenTOTPLoginModal();
|
||||
|
||||
const isFormLogin = Boolean(window.__lastLoginData);
|
||||
if (!isFormLogin) {
|
||||
// disable Basic‑Auth link
|
||||
const basicLink = document.querySelector("a[href='login_basic.php']");
|
||||
if (basicLink) {
|
||||
basicLink.style.pointerEvents = 'none';
|
||||
basicLink.style.opacity = '0.5';
|
||||
}
|
||||
// disable OIDC button
|
||||
const oidcBtn = document.getElementById("oidcLoginBtn");
|
||||
if (oidcBtn) {
|
||||
oidcBtn.disabled = true;
|
||||
oidcBtn.style.opacity = '0.5';
|
||||
}
|
||||
// hide the form login
|
||||
const authForm = document.getElementById("authForm");
|
||||
if (authForm) authForm.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------- Utility Functions ----------------- */
|
||||
function updateItemsPerPageSelect() {
|
||||
const selectElem = document.querySelector(".form-control.bottom-select");
|
||||
@@ -51,18 +97,36 @@ function loadAdminConfigFunc() {
|
||||
return fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
// Save header_title into localStorage (if needed)
|
||||
localStorage.setItem("headerTitle", config.header_title || "FileRise");
|
||||
|
||||
// Update login options and global OTPAuth URL as before
|
||||
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
|
||||
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
|
||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/FileRise?issuer=FileRise");
|
||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||
|
||||
// Update the UI for login options
|
||||
updateLoginOptionsUIFromStorage();
|
||||
|
||||
const headerTitleElem = document.querySelector(".header-title h1");
|
||||
if (headerTitleElem) {
|
||||
headerTitleElem.textContent = config.header_title || "FileRise";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback defaults in case of error
|
||||
localStorage.setItem("headerTitle", "FileRise");
|
||||
localStorage.setItem("disableFormLogin", "false");
|
||||
localStorage.setItem("disableBasicAuth", "false");
|
||||
localStorage.setItem("disableOIDCLogin", "false");
|
||||
localStorage.setItem("globalOtpauthUrl", "otpauth://totp/FileRise?issuer=FileRise");
|
||||
localStorage.setItem("globalOtpauthUrl", "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||
updateLoginOptionsUIFromStorage();
|
||||
|
||||
const headerTitleElem = document.querySelector(".header-title h1");
|
||||
if (headerTitleElem) {
|
||||
headerTitleElem.textContent = "FileRise";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,14 +147,14 @@ function updateAuthenticatedUI(data) {
|
||||
if (typeof data.totp_enabled !== "undefined") {
|
||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||
}
|
||||
|
||||
if (data.username) {
|
||||
localStorage.setItem("username", data.username);
|
||||
}
|
||||
/*
|
||||
if (typeof data.folderOnly !== "undefined") {
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
}
|
||||
|
||||
*/
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
|
||||
@@ -100,12 +164,10 @@ function updateAuthenticatedUI(data) {
|
||||
restoreBtn = document.createElement("button");
|
||||
restoreBtn.id = "restoreFilesBtn";
|
||||
restoreBtn.classList.add("btn", "btn-warning");
|
||||
restoreBtn.innerHTML = '<i class="material-icons" title="Restore/Delete Trash">restore_from_trash</i>';
|
||||
if (firstButton) {
|
||||
insertAfter(restoreBtn, firstButton);
|
||||
} else {
|
||||
headerButtons.appendChild(restoreBtn);
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -114,7 +176,8 @@ function updateAuthenticatedUI(data) {
|
||||
adminPanelBtn = document.createElement("button");
|
||||
adminPanelBtn.id = "adminPanelBtn";
|
||||
adminPanelBtn.classList.add("btn", "btn-info");
|
||||
adminPanelBtn.innerHTML = '<i class="material-icons" title="Admin Panel">admin_panel_settings</i>';
|
||||
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 {
|
||||
@@ -127,28 +190,25 @@ function updateAuthenticatedUI(data) {
|
||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
||||
}
|
||||
|
||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
||||
if (!userPanelBtn) {
|
||||
userPanelBtn = document.createElement("button");
|
||||
userPanelBtn.id = "userPanelBtn";
|
||||
userPanelBtn.classList.add("btn", "btn-user");
|
||||
userPanelBtn.innerHTML = '<i class="material-icons" title="User Panel">account_circle</i>';
|
||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
||||
if (adminPanelBtn) {
|
||||
insertAfter(userPanelBtn, adminPanelBtn);
|
||||
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>';
|
||||
|
||||
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 {
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
if (firstButton) {
|
||||
insertAfter(userPanelBtn, firstButton);
|
||||
} else {
|
||||
headerButtons.appendChild(userPanelBtn);
|
||||
}
|
||||
userPanelBtn.style.display = "block";
|
||||
}
|
||||
userPanelBtn.addEventListener("click", openUserPanel);
|
||||
} else {
|
||||
userPanelBtn.style.display = "block";
|
||||
}
|
||||
|
||||
applyTranslations();
|
||||
updateItemsPerPageSelect();
|
||||
updateLoginOptionsUIFromStorage();
|
||||
}
|
||||
@@ -189,11 +249,26 @@ function checkAuthentication(showLoginToast = true) {
|
||||
/* ----------------- Authentication Submission ----------------- */
|
||||
function submitLogin(data) {
|
||||
setLastLoginData(data);
|
||||
window.__lastLoginData = data;
|
||||
sendRequest("auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
if (response.success || response.status === "ok") {
|
||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
|
||||
window.location.reload();
|
||||
// Fetch and update permissions, then reload.
|
||||
sendRequest("getUserPermissions.php", "GET")
|
||||
.then(permissionData => {
|
||||
if (permissionData && typeof permissionData === "object") {
|
||||
localStorage.setItem("folderOnly", permissionData.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", permissionData.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload", permissionData.disableUpload ? "true" : "false");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// if fetching permissions fails.
|
||||
})
|
||||
.finally(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
} else if (response.totp_required) {
|
||||
openTOTPLoginModal();
|
||||
} else if (response.error && response.error.includes("Too many failed login attempts")) {
|
||||
@@ -216,7 +291,7 @@ function submitLogin(data) {
|
||||
}
|
||||
window.submitLogin = submitLogin;
|
||||
|
||||
/* ----------------- Other Helpers and Initialization ----------------- */
|
||||
/* ----------------- Other Helpers ----------------- */
|
||||
window.changeItemsPerPage = function (value) {
|
||||
localStorage.setItem("itemsPerPage", value);
|
||||
if (typeof renderFileTable === "function") renderFileTable(window.currentFolder || "root");
|
||||
@@ -400,6 +475,19 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
|
||||
});
|
||||
|
||||
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
|
||||
if (oidcLoginBtn) {
|
||||
oidcLoginBtn.addEventListener("click", () => {
|
||||
window.location.href = "auth.php?oidc=initiate";
|
||||
});
|
||||
}
|
||||
|
||||
// If TOTP is pending, show modal and skip normal auth init
|
||||
if (window.pendingTOTP) {
|
||||
openTOTPLoginModal();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
export { initAuth, checkAuthentication };
|
||||
976
js/authModals.js
Normal file
@@ -0,0 +1,976 @@
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||
|
||||
const version = "v1.1.3";
|
||||
// Use t() for the admin panel title. (Make sure t("admin_panel") returns "Admin Panel" in English.)
|
||||
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
|
||||
|
||||
let lastLoginData = null;
|
||||
export function setLastLoginData(data) {
|
||||
lastLoginData = data;
|
||||
// expose to auth.js so it can tell form-login vs basic/oidc
|
||||
//window.__lastLoginData = data;
|
||||
}
|
||||
|
||||
export function openTOTPLoginModal() {
|
||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const modalBg = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
const textColor = isDarkMode ? "#e0e0e0" : "#000";
|
||||
|
||||
if (!totpLoginModal) {
|
||||
totpLoginModal = document.createElement("div");
|
||||
totpLoginModal.id = "totpLoginModal";
|
||||
totpLoginModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 3200;
|
||||
`;
|
||||
totpLoginModal.innerHTML = `
|
||||
<div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};">
|
||||
<span id="closeTOTPLoginModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">×</span>
|
||||
<div id="totpSection">
|
||||
<h3>${t("enter_totp_code")}</h3>
|
||||
<input type="text" id="totpLoginInput" maxlength="6"
|
||||
style="font-size:24px; text-align:center; width:100%; padding:10px;"
|
||||
placeholder="6-digit code" />
|
||||
</div>
|
||||
<a href="#" id="toggleRecovery" style="display:block; margin-top:10px; font-size:14px;">${t("use_recovery_code_instead")}</a>
|
||||
<div id="recoverySection" style="display:none; margin-top:10px;">
|
||||
<h3>${t("enter_recovery_code")}</h3>
|
||||
<input type="text" id="recoveryInput"
|
||||
style="font-size:24px; text-align:center; width:100%; padding:10px;"
|
||||
placeholder="Recovery code" />
|
||||
<button type="button" id="submitRecovery" class="btn btn-secondary" style="margin-top:10px;">${t("submit_recovery_code")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(totpLoginModal);
|
||||
|
||||
// Close button
|
||||
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
|
||||
totpLoginModal.style.display = "none";
|
||||
});
|
||||
|
||||
// Toggle between TOTP and Recovery
|
||||
document.getElementById("toggleRecovery").addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
const totpSection = document.getElementById("totpSection");
|
||||
const recoverySection = document.getElementById("recoverySection");
|
||||
const toggleLink = this;
|
||||
|
||||
if (recoverySection.style.display === "none") {
|
||||
// Switch to recovery
|
||||
totpSection.style.display = "none";
|
||||
recoverySection.style.display = "block";
|
||||
toggleLink.textContent = t("use_totp_code_instead");
|
||||
} else {
|
||||
// Switch back to TOTP
|
||||
recoverySection.style.display = "none";
|
||||
totpSection.style.display = "block";
|
||||
toggleLink.textContent = t("use_recovery_code_instead");
|
||||
}
|
||||
});
|
||||
|
||||
// Recovery submission
|
||||
document.getElementById("submitRecovery").addEventListener("click", () => {
|
||||
const recoveryCode = document.getElementById("recoveryInput").value.trim();
|
||||
if (!recoveryCode) {
|
||||
showToast(t("please_enter_recovery_code"));
|
||||
return;
|
||||
}
|
||||
fetch("totp_recover.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ recovery_code: recoveryCode })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.status === "ok") {
|
||||
// recovery succeeded → finalize login
|
||||
window.location.href = "index.html";
|
||||
} else {
|
||||
showToast(json.message || t("recovery_code_verification_failed"));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(t("error_verifying_recovery_code"));
|
||||
});
|
||||
});
|
||||
|
||||
// TOTP submission
|
||||
const totpInput = document.getElementById("totpLoginInput");
|
||||
totpInput.focus();
|
||||
totpInput.addEventListener("input", function () {
|
||||
const code = this.value.trim();
|
||||
if (code.length === 6) {
|
||||
fetch("totp_verify.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_code: code })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.status === "ok") {
|
||||
window.location.href = "index.html";
|
||||
} else {
|
||||
showToast(json.message || t("totp_verification_failed"));
|
||||
this.value = "";
|
||||
totpLoginModal.style.display = "flex";
|
||||
totpInput.focus();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(t("totp_verification_failed"));
|
||||
this.value = "";
|
||||
totpLoginModal.style.display = "flex";
|
||||
totpInput.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Re-open existing modal
|
||||
totpLoginModal.style.display = "flex";
|
||||
const totpInput = document.getElementById("totpLoginInput");
|
||||
totpInput.value = "";
|
||||
totpInput.style.display = "block";
|
||||
totpInput.focus();
|
||||
document.getElementById("recoverySection").style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function openUserPanel() {
|
||||
const username = localStorage.getItem("username") || "User";
|
||||
let userPanelModal = document.getElementById("userPanelModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: fixed;
|
||||
overflow-y: auto;
|
||||
max-height: 350px !important;
|
||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||
transform: none;
|
||||
transition: none;
|
||||
`;
|
||||
// Retrieve the language setting from local storage, default to English ("en")
|
||||
const savedLanguage = localStorage.getItem("language") || "en";
|
||||
if (!userPanelModal) {
|
||||
userPanelModal = document.createElement("div");
|
||||
userPanelModal.id = "userPanelModal";
|
||||
userPanelModal.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: 3000;
|
||||
`;
|
||||
userPanelModal.innerHTML = `
|
||||
<div class="modal-content user-panel-content" style="${modalContentStyles}">
|
||||
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>${t("user_panel")} (${username})</h3>
|
||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">${t("change_password")}</button>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>${t("totp_settings")}</legend>
|
||||
<div class="form-group">
|
||||
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
|
||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>${t("language")}</legend>
|
||||
<div class="form-group">
|
||||
<label for="languageSelector">${t("select_language")}:</label>
|
||||
<select id="languageSelector">
|
||||
<option value="en">${t("english")}</option>
|
||||
<option value="es">${t("spanish")}</option>
|
||||
<option value="fr">${t("french")}</option>
|
||||
<option value="de">${t("german")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(userPanelModal);
|
||||
// Close button handler
|
||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
||||
userPanelModal.style.display = "none";
|
||||
});
|
||||
// Change Password button
|
||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
});
|
||||
// TOTP checkbox behavior
|
||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
||||
totpCheckbox.addEventListener("change", function () {
|
||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
||||
const enabled = this.checked;
|
||||
fetch("updateUserPanel.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_enabled: enabled })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) {
|
||||
showToast(t("error_updating_totp_setting") + ": " + result.error);
|
||||
} else if (enabled) {
|
||||
openTOTPModal();
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast(t("error_updating_totp_setting")); });
|
||||
});
|
||||
// Language dropdown initialization
|
||||
const languageSelector = document.getElementById("languageSelector");
|
||||
languageSelector.value = savedLanguage;
|
||||
languageSelector.addEventListener("change", function () {
|
||||
const selectedLanguage = this.value;
|
||||
localStorage.setItem("language", selectedLanguage);
|
||||
setLocale(selectedLanguage);
|
||||
applyTranslations();
|
||||
});
|
||||
} else {
|
||||
// If the modal already exists, update its colors
|
||||
userPanelModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||
}
|
||||
userPanelModal.style.display = "flex";
|
||||
}
|
||||
|
||||
function showRecoveryCodeModal(recoveryCode) {
|
||||
const recoveryModal = document.createElement("div");
|
||||
recoveryModal.id = "recoveryModal";
|
||||
recoveryModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3200;
|
||||
`;
|
||||
recoveryModal.innerHTML = `
|
||||
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
|
||||
<h3>${t("your_recovery_code")}</h3>
|
||||
<p>${t("please_save_recovery_code")}</p>
|
||||
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
|
||||
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(recoveryModal);
|
||||
|
||||
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
||||
recoveryModal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
export function openTOTPModal() {
|
||||
let totpModal = document.getElementById("totpModal");
|
||||
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: 400px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
`;
|
||||
if (!totpModal) {
|
||||
totpModal = document.createElement("div");
|
||||
totpModal.id = "totpModal";
|
||||
totpModal.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: 3100;
|
||||
`;
|
||||
totpModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeTOTPModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>${t("totp_setup")}</h3>
|
||||
<p>${t("scan_qr_code")}</p>
|
||||
<!-- Create an image placeholder without the CSRF token in the src -->
|
||||
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
||||
<br/>
|
||||
<p>${t("enter_totp_confirmation")}</p>
|
||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||
<br/><br/>
|
||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(totpModal);
|
||||
loadTOTPQRCode();
|
||||
|
||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
||||
closeTOTPModal(true);
|
||||
});
|
||||
|
||||
document.getElementById("confirmTOTPBtn").addEventListener("click", function () {
|
||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||
if (code.length !== 6) {
|
||||
showToast(t("please_enter_valid_code"));
|
||||
return;
|
||||
}
|
||||
fetch("totp_verify.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_code: code })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.status === 'ok') {
|
||||
showToast(t("totp_enabled_successfully"));
|
||||
// After successful TOTP verification, fetch the recovery code
|
||||
fetch("totp_saveCode.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok' && data.recoveryCode) {
|
||||
// Show the recovery code in a secure modal
|
||||
showRecoveryCodeModal(data.recoveryCode);
|
||||
} else {
|
||||
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast(t("error_generating_recovery_code")); });
|
||||
closeTOTPModal(false);
|
||||
} else {
|
||||
showToast(t("totp_verification_failed") + ": " + (result.message || t("invalid_code")));
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast(t("error_verifying_totp_code")); });
|
||||
});
|
||||
|
||||
// Focus the input and attach enter key listener
|
||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||||
if (totpConfirmInput) {
|
||||
setTimeout(() => {
|
||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||||
if (totpConfirmInput) totpConfirmInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||
|
||||
} else {
|
||||
totpModal.style.display = "flex";
|
||||
totpModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = totpModal.querySelector(".modal-content");
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
|
||||
// Clear any previous QR code src if needed and then load it:
|
||||
const qrImg = document.getElementById("totpQRCodeImage");
|
||||
if (qrImg) {
|
||||
qrImg.src = "";
|
||||
}
|
||||
loadTOTPQRCode();
|
||||
|
||||
// Focus the input and attach enter key listener
|
||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||||
if (totpConfirmInput) {
|
||||
totpConfirmInput.value = "";
|
||||
setTimeout(() => {
|
||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||||
if (totpConfirmInput) totpConfirmInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||
}
|
||||
}
|
||||
|
||||
function loadTOTPQRCode() {
|
||||
fetch("totp_setup.php", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"X-CSRF-Token": window.csrfToken // Send your CSRF token here
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch QR code. Status: " + response.status);
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const imageURL = URL.createObjectURL(blob);
|
||||
const qrImg = document.getElementById("totpQRCodeImage");
|
||||
if (qrImg) {
|
||||
qrImg.src = imageURL;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading TOTP QR code:", error);
|
||||
showToast(t("error_loading_qr_code"));
|
||||
});
|
||||
}
|
||||
|
||||
// Updated closeTOTPModal function with a disable parameter
|
||||
export function closeTOTPModal(disable = true) {
|
||||
const totpModal = document.getElementById("totpModal");
|
||||
if (totpModal) totpModal.style.display = "none";
|
||||
|
||||
if (disable) {
|
||||
// Uncheck the Enable TOTP checkbox
|
||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||
if (totpCheckbox) {
|
||||
totpCheckbox.checked = false;
|
||||
localStorage.setItem("userTOTPEnabled", "false");
|
||||
}
|
||||
// Call endpoint to remove the TOTP secret from the user's record
|
||||
fetch("totp_disable.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) {
|
||||
showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast(t("error_disabling_totp_setting")); });
|
||||
}
|
||||
}
|
||||
|
||||
// Global variable to hold the initial state of the admin form.
|
||||
let originalAdminConfig = {};
|
||||
|
||||
// Capture the initial state of the admin form fields.
|
||||
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,
|
||||
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
// Compare current values to the captured initial state.
|
||||
function hasUnsavedChanges() {
|
||||
return (
|
||||
document.getElementById("headerTitle").value.trim() !== originalAdminConfig.headerTitle ||
|
||||
document.getElementById("oidcProviderUrl").value.trim() !== originalAdminConfig.oidcProviderUrl ||
|
||||
document.getElementById("oidcClientId").value.trim() !== originalAdminConfig.oidcClientId ||
|
||||
document.getElementById("oidcClientSecret").value.trim() !== originalAdminConfig.oidcClientSecret ||
|
||||
document.getElementById("oidcRedirectUri").value.trim() !== originalAdminConfig.oidcRedirectUri ||
|
||||
document.getElementById("disableFormLogin").checked !== originalAdminConfig.disableFormLogin ||
|
||||
document.getElementById("disableBasicAuth").checked !== originalAdminConfig.disableBasicAuth ||
|
||||
document.getElementById("disableOIDCLogin").checked !== originalAdminConfig.disableOIDCLogin ||
|
||||
document.getElementById("globalOtpauthUrl").value.trim() !== originalAdminConfig.globalOtpauthUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Use your custom confirmation modal.
|
||||
function showCustomConfirmModal(message) {
|
||||
return new Promise((resolve) => {
|
||||
// Get modal elements from DOM.
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
const messageElem = document.getElementById("confirmMessage");
|
||||
const yesBtn = document.getElementById("confirmYesBtn");
|
||||
const noBtn = document.getElementById("confirmNoBtn");
|
||||
|
||||
// Set the message in the modal.
|
||||
messageElem.textContent = message;
|
||||
modal.style.display = "block";
|
||||
|
||||
// Define event handlers.
|
||||
function onYes() {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
}
|
||||
function onNo() {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}
|
||||
// Remove event listeners and hide modal after choice.
|
||||
function cleanup() {
|
||||
yesBtn.removeEventListener("click", onYes);
|
||||
noBtn.removeEventListener("click", onNo);
|
||||
modal.style.display = "none";
|
||||
}
|
||||
|
||||
yesBtn.addEventListener("click", onYes);
|
||||
noBtn.addEventListener("click", onNo);
|
||||
});
|
||||
}
|
||||
|
||||
export function openAdminPanel() {
|
||||
fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
if (config.header_title) {
|
||||
document.querySelector(".header-title h1").textContent = config.header_title;
|
||||
window.headerTitle = config.header_title || "FileRise";
|
||||
}
|
||||
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
|
||||
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||
`;
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
|
||||
if (!adminModal) {
|
||||
adminModal = document.createElement("div");
|
||||
adminModal.id = "adminPanelModal";
|
||||
adminModal.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: 3000;
|
||||
`;
|
||||
adminModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeAdminPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>${adminTitle}</h3>
|
||||
<form id="adminPanelForm">
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>${t("user_management")}</legend>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="button" id="adminOpenAddUser" class="btn btn-success">${t("add_user")}</button>
|
||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger">${t("remove_user")}</button>
|
||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${t("user_permissions")}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>Header Settings</legend>
|
||||
<div class="form-group">
|
||||
<label for="headerTitle">Header Title:</label>
|
||||
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle}" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>${t("login_options")}</legend>
|
||||
<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>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>${t("oidc_configuration")}</legend>
|
||||
<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>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>${t("global_totp_settings")}</legend>
|
||||
<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>
|
||||
</fieldset>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<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(adminModal);
|
||||
|
||||
// Bind closing events that will use our enhanced close function.
|
||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||
adminModal.addEventListener("click", (e) => {
|
||||
if (e.target === adminModal) closeAdminPanel();
|
||||
});
|
||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||
|
||||
// Bind other buttons.
|
||||
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();
|
||||
});
|
||||
document.getElementById("saveAdminSettings").addEventListener("click", () => {
|
||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
||||
if (totalDisabled === 3) {
|
||||
showToast(t("at_least_one_login_method"));
|
||||
disableOIDCLoginCheckbox.checked = false;
|
||||
localStorage.setItem("disableOIDCLogin", "false");
|
||||
if (typeof window.updateLoginOptionsUI === "function") {
|
||||
window.updateLoginOptionsUI({
|
||||
disableFormLogin: disableFormLoginCheckbox.checked,
|
||||
disableBasicAuth: disableBasicAuthCheckbox.checked,
|
||||
disableOIDCLogin: disableOIDCLoginCheckbox.checked
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
|
||||
const newOIDCConfig = {
|
||||
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 disableFormLogin = disableFormLoginCheckbox.checked;
|
||||
const disableBasicAuth = disableBasicAuthCheckbox.checked;
|
||||
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
|
||||
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
|
||||
sendRequest("updateConfig.php", "POST", {
|
||||
header_title: newHeaderTitle,
|
||||
oidc: newOIDCConfig,
|
||||
disableFormLogin,
|
||||
disableBasicAuth,
|
||||
disableOIDCLogin,
|
||||
globalOtpauthUrl
|
||||
}, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast(t("settings_updated_successfully"));
|
||||
localStorage.setItem("disableFormLogin", disableFormLogin);
|
||||
localStorage.setItem("disableBasicAuth", disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
|
||||
if (typeof window.updateLoginOptionsUI === "function") {
|
||||
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
|
||||
}
|
||||
// Update the captured initial state since the changes have now been saved.
|
||||
captureInitialAdminConfig();
|
||||
closeAdminPanel();
|
||||
|
||||
} else {
|
||||
showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error")));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
});
|
||||
// Enforce login option constraints.
|
||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||
function enforceLoginOptionConstraint(changedCheckbox) {
|
||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
||||
if (changedCheckbox.checked && totalDisabled === 3) {
|
||||
showToast(t("at_least_one_login_method"));
|
||||
changedCheckbox.checked = false;
|
||||
}
|
||||
}
|
||||
disableFormLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
|
||||
// Capture initial state after the modal loads.
|
||||
captureInitialAdminConfig();
|
||||
} else {
|
||||
adminModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = adminModal.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||
}
|
||||
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 || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise';
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
adminModal.style.display = "flex";
|
||||
captureInitialAdminConfig();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) {
|
||||
adminModal.style.backgroundColor = "rgba(0,0,0,0.5)";
|
||||
const modalContent = adminModal.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.style.background = "#fff";
|
||||
modalContent.style.color = "#000";
|
||||
modalContent.style.border = "1px solid #ccc";
|
||||
}
|
||||
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 || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise';
|
||||
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
|
||||
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
|
||||
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
|
||||
adminModal.style.display = "flex";
|
||||
captureInitialAdminConfig();
|
||||
} else {
|
||||
openAdminPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeAdminPanel() {
|
||||
if (hasUnsavedChanges()) {
|
||||
const userConfirmed = await showCustomConfirmModal(t("unsaved_changes_confirm"));
|
||||
if (!userConfirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) adminModal.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" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</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("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("getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
// Then, fetch the list of users.
|
||||
return fetch("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>";
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// domUtils.js
|
||||
import { t } from './i18n.js';
|
||||
import { openDownloadModal } from './fileActions.js';
|
||||
|
||||
// Basic DOM Helpers
|
||||
export function toggleVisibility(elementId, shouldShow) {
|
||||
@@ -6,7 +8,7 @@ export function toggleVisibility(elementId, shouldShow) {
|
||||
if (element) {
|
||||
element.style.display = shouldShow ? "block" : "none";
|
||||
} else {
|
||||
console.error(`Element with id "${elementId}" not found.`);
|
||||
console.error(t("element_not_found", { id: elementId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,23 +90,36 @@ 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
|
||||
? t("search_placeholder_advanced")
|
||||
: t("search_placeholder");
|
||||
|
||||
return `
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-12 col-md-8 mb-2 mb-md-0">
|
||||
<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")}">
|
||||
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search Icon -->
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text" id="searchIcon">
|
||||
<i class="material-icons">search</i>
|
||||
</span>
|
||||
</div>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="Search files or tag..." value="${safeSearchTerm}" aria-describedby="searchIcon">
|
||||
<!-- Search Input -->
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="${placeholderText}" value="${safeSearchTerm}" aria-describedby="searchIcon">
|
||||
</div>
|
||||
</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})">Prev</button>
|
||||
<span class="page-indicator">Page ${currentPage} of ${totalPages || 1}</span>
|
||||
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">Next</button>
|
||||
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,12 +132,12 @@ export function buildFileTableHeader(sortOrder) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th>
|
||||
<th data-column="name" class="sortable-col">File Name ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="modified" class="hide-small sortable-col">Date Modified ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploaded" class="hide-small hide-medium sortable-col">Upload Date ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="size" class="hide-small sortable-col">File Size ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploader" class="hide-small hide-medium sortable-col">Uploader ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th>Actions</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>
|
||||
<th data-column="size" class="hide-small sortable-col">${t("file_size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th>${t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
`;
|
||||
@@ -136,11 +151,11 @@ export function buildFileTableRow(file, folderPath) {
|
||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||
|
||||
let previewButton = "";
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
|
||||
let previewIcon = "";
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">image</i>`;
|
||||
} else if (/\.(mp4|webm|mov)$/i.test(file.name)) {
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
||||
} else if (/\.pdf$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
|
||||
@@ -164,38 +179,40 @@ export function buildFileTableRow(file, folderPath) {
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td>
|
||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
||||
<a class="btn btn-sm btn-success download-btn"
|
||||
href="download.php?folder=${encodeURIComponent(file.folder || 'root')}&file=${encodeURIComponent(file.name)}"
|
||||
title="Download">
|
||||
<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>
|
||||
</a>
|
||||
</button>
|
||||
${file.editable ? `
|
||||
<button class="btn btn-sm edit-btn"
|
||||
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
||||
title="Edit">
|
||||
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="Rename">
|
||||
title="${t('rename')}">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildBottomControls(itemsPerPageSetting) {
|
||||
return `
|
||||
<div class="d-flex align-items-center mt-3 bottom-controls">
|
||||
<label class="label-inline mr-2 mb-0">Show</label>
|
||||
<label class="label-inline mr-2 mb-0">${t("show")}</label>
|
||||
<select class="form-control bottom-select" onchange="changeItemsPerPage(this.value)">
|
||||
${[10, 20, 50, 100].map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`).join("")}
|
||||
${[10, 20, 50, 100]
|
||||
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
|
||||
.join("")}
|
||||
</select>
|
||||
<span class="items-per-page-text ml-2 mb-0">items per page</span>
|
||||
<span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -221,15 +238,63 @@ export function updateRowHighlight(checkbox) {
|
||||
}
|
||||
|
||||
export function toggleRowSelection(event, fileName) {
|
||||
// Prevent default text selection when shift is held.
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Ignore clicks on interactive elements.
|
||||
const targetTag = event.target.tagName.toLowerCase();
|
||||
if (targetTag === 'a' || targetTag === 'button' || targetTag === 'input') {
|
||||
if (["a", "button", "input"].includes(targetTag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the clicked row and its checkbox.
|
||||
const row = event.currentTarget;
|
||||
const checkbox = row.querySelector('.file-checkbox');
|
||||
const checkbox = row.querySelector(".file-checkbox");
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateRowHighlight(checkbox);
|
||||
|
||||
// Get all rows in the current file list view.
|
||||
const allRows = Array.from(document.querySelectorAll("#fileList tbody tr"));
|
||||
|
||||
// Helper: clear all selections (not used in this updated version).
|
||||
const clearAllSelections = () => {
|
||||
allRows.forEach(r => {
|
||||
const cb = r.querySelector(".file-checkbox");
|
||||
if (cb) {
|
||||
cb.checked = false;
|
||||
updateRowHighlight(cb);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// If the user is holding the Shift key, perform range selection.
|
||||
if (event.shiftKey) {
|
||||
// Use the last clicked row as the anchor.
|
||||
const lastRow = window.lastSelectedFileRow || row;
|
||||
const currentIndex = allRows.indexOf(row);
|
||||
const lastIndex = allRows.indexOf(lastRow);
|
||||
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) {
|
||||
cb.checked = true;
|
||||
updateRowHighlight(cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, for all non-shift clicks simply toggle the selected state.
|
||||
else {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateRowHighlight(checkbox);
|
||||
}
|
||||
|
||||
// Update the anchor row to the row that was clicked.
|
||||
window.lastSelectedFileRow = row;
|
||||
updateFileActionButtons();
|
||||
}
|
||||
|
||||
@@ -239,7 +304,7 @@ export function attachEnterKeyListener(modalId, buttonId) {
|
||||
// Make the modal focusable
|
||||
modal.setAttribute("tabindex", "-1");
|
||||
modal.focus();
|
||||
modal.addEventListener("keydown", function(e) {
|
||||
modal.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById(buttonId);
|
||||
599
js/dragAndDrop.js
Normal file
@@ -0,0 +1,599 @@
|
||||
// dragAndDrop.js
|
||||
// This file handles drag-and-drop functionality for cards in the sidebar, header and top drop zones.
|
||||
// It also manages the visibility of the sidebar and header drop zones based on the current state of the application.
|
||||
// It includes functions to save and load the order of cards in the sidebar and header from localStorage.
|
||||
// It also includes functions to handle the drag-and-drop events, including mouse movements and drop zones.
|
||||
// It uses CSS classes to manage the appearance of the sidebar and header drop zones during drag-and-drop operations.
|
||||
|
||||
// Moves cards into the sidebar based on the saved order in localStorage.
|
||||
export function loadSidebarOrder() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const orderStr = localStorage.getItem('sidebarOrder');
|
||||
if (orderStr) {
|
||||
const order = JSON.parse(orderStr);
|
||||
if (order.length > 0) {
|
||||
// Ensure main wrapper is visible.
|
||||
const mainWrapper = document.querySelector('.main-wrapper');
|
||||
if (mainWrapper) {
|
||||
mainWrapper.style.display = 'flex';
|
||||
}
|
||||
// For each saved ID, move the card into the sidebar.
|
||||
order.forEach(id => {
|
||||
const card = document.getElementById(id);
|
||||
if (card && card.parentNode.id !== 'sidebarDropArea') {
|
||||
sidebar.appendChild(card);
|
||||
// Animate vertical slide for sidebar card
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper: update sidebar visibility based on its content.
|
||||
function updateSidebarVisibility() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
const cards = sidebar.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
if (cards.length > 0) {
|
||||
sidebar.classList.add('active');
|
||||
sidebar.style.display = 'block';
|
||||
} else {
|
||||
sidebar.classList.remove('active');
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
// Save the current order in localStorage.
|
||||
saveSidebarOrder();
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Save header order to localStorage.
|
||||
function saveHeaderOrder() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (headerDropArea) {
|
||||
const icons = Array.from(headerDropArea.children);
|
||||
// Each header icon stores its associated card in the property cardElement.
|
||||
const order = icons.map(icon => icon.cardElement.id);
|
||||
localStorage.setItem('headerOrder', JSON.stringify(order));
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper: update top zone layout (center a card if one column is empty).
|
||||
function updateTopZoneLayout() {
|
||||
const leftCol = document.getElementById('leftCol');
|
||||
const rightCol = document.getElementById('rightCol');
|
||||
|
||||
const leftIsEmpty = !leftCol.querySelector('#uploadCard');
|
||||
const rightIsEmpty = !rightCol.querySelector('#folderManagementCard');
|
||||
|
||||
if (leftIsEmpty && !rightIsEmpty) {
|
||||
leftCol.style.display = 'none';
|
||||
rightCol.style.margin = '0 auto';
|
||||
} else if (rightIsEmpty && !leftIsEmpty) {
|
||||
rightCol.style.display = 'none';
|
||||
leftCol.style.margin = '0 auto';
|
||||
} else {
|
||||
leftCol.style.display = '';
|
||||
rightCol.style.display = '';
|
||||
leftCol.style.margin = '';
|
||||
rightCol.style.margin = '';
|
||||
}
|
||||
}
|
||||
|
||||
// When a card is being dragged, if the top drop zone is empty, set its min-height.
|
||||
function addTopZoneHighlight() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (topZone) {
|
||||
topZone.classList.add('highlight');
|
||||
if (topZone.querySelectorAll('#uploadCard, #folderManagementCard').length === 0) {
|
||||
topZone.style.minHeight = '375px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the drag ends, remove the extra min-height.
|
||||
function removeTopZoneHighlight() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (topZone) {
|
||||
topZone.classList.remove('highlight');
|
||||
topZone.style.minHeight = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical slide/fade animation helper.
|
||||
function animateVerticalSlide(card) {
|
||||
card.style.transform = 'translateY(30px)';
|
||||
card.style.opacity = '0';
|
||||
// Force reflow.
|
||||
card.offsetWidth;
|
||||
requestAnimationFrame(() => {
|
||||
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
|
||||
card.style.transform = 'translateY(0)';
|
||||
card.style.opacity = '1';
|
||||
});
|
||||
setTimeout(() => {
|
||||
card.style.transition = '';
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
}, 310);
|
||||
}
|
||||
|
||||
// Internal helper: insert card into sidebar at a proper position based on event.clientY.
|
||||
function insertCardInSidebar(card, event) {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const existingCards = Array.from(sidebar.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
let inserted = false;
|
||||
for (const currentCard of existingCards) {
|
||||
const rect = currentCard.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (event.clientY < midY) {
|
||||
sidebar.insertBefore(card, currentCard);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inserted) {
|
||||
sidebar.appendChild(card);
|
||||
}
|
||||
// Ensure card fills the sidebar.
|
||||
card.style.width = '100%';
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
|
||||
// Internal helper: save the current sidebar card order to localStorage.
|
||||
function saveSidebarOrder() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
const cards = sidebar.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
const order = Array.from(cards).map(card => card.id);
|
||||
localStorage.setItem('sidebarOrder', JSON.stringify(order));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: move cards from sidebar back to the top drop area when on small screens.
|
||||
function moveSidebarCardsToTop() {
|
||||
if (window.innerWidth < 1205) {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const cards = Array.from(sidebar.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
cards.forEach(card => {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
});
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for window resize to automatically move sidebar cards back to top on small screens.
|
||||
window.addEventListener('resize', function () {
|
||||
if (window.innerWidth < 1205) {
|
||||
moveSidebarCardsToTop();
|
||||
}
|
||||
});
|
||||
|
||||
// This function ensures the top drop zone (#uploadFolderRow) has a stable width when empty.
|
||||
function ensureTopZonePlaceholder() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (!topZone) return;
|
||||
if (topZone.querySelectorAll('#uploadCard, #folderManagementCard').length === 0) {
|
||||
let placeholder = topZone.querySelector('.placeholder');
|
||||
if (!placeholder) {
|
||||
placeholder = document.createElement('div');
|
||||
placeholder.className = 'placeholder';
|
||||
placeholder.style.visibility = 'hidden';
|
||||
placeholder.style.display = 'block';
|
||||
placeholder.style.width = '100%';
|
||||
placeholder.style.height = '375px';
|
||||
topZone.appendChild(placeholder);
|
||||
}
|
||||
} else {
|
||||
const placeholder = topZone.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// --- NEW HELPER FUNCTIONS FOR HEADER DROP ZONE ---
|
||||
|
||||
// Show header drop zone and add a "drag-active" class so that the pseudo-element appears.
|
||||
function showHeaderDropZone() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (headerDropArea) {
|
||||
headerDropArea.style.display = 'inline-flex';
|
||||
headerDropArea.classList.add('drag-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide header drop zone by removing the "drag-active" class.
|
||||
// If a header icon is present (i.e. a card was dropped), the drop zone remains visible without the dashed border.
|
||||
function hideHeaderDropZone() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (headerDropArea) {
|
||||
headerDropArea.classList.remove('drag-active');
|
||||
if (headerDropArea.children.length === 0) {
|
||||
headerDropArea.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === NEW FUNCTION: Insert card into header drop zone as a material icon ===
|
||||
function insertCardInHeader(card, event) {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (!headerDropArea) return;
|
||||
|
||||
// For folder management and upload cards, preserve the original by moving it to a hidden container.
|
||||
if (card.id === 'folderManagementCard' || card.id === 'uploadCard') {
|
||||
let hiddenContainer = document.getElementById('hiddenCardsContainer');
|
||||
if (!hiddenContainer) {
|
||||
hiddenContainer = document.createElement('div');
|
||||
hiddenContainer.id = 'hiddenCardsContainer';
|
||||
hiddenContainer.style.display = 'none';
|
||||
document.body.appendChild(hiddenContainer);
|
||||
}
|
||||
// Move the original card to the hidden container if it's not already there.
|
||||
if (card.parentNode.id !== 'hiddenCardsContainer') {
|
||||
hiddenContainer.appendChild(card);
|
||||
}
|
||||
} else {
|
||||
// For other cards, simply remove from current container.
|
||||
if (card.parentNode) {
|
||||
card.parentNode.removeChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the header icon button.
|
||||
const iconButton = document.createElement('button');
|
||||
iconButton.className = 'header-card-icon';
|
||||
// Remove default button styling.
|
||||
iconButton.style.border = 'none';
|
||||
iconButton.style.background = 'none';
|
||||
iconButton.style.outline = 'none';
|
||||
iconButton.style.cursor = 'pointer';
|
||||
|
||||
// Choose an icon based on the card type with 24px size.
|
||||
if (card.id === 'uploadCard') {
|
||||
iconButton.innerHTML = '<i class="material-icons" style="font-size:24px;">cloud_upload</i>';
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
iconButton.innerHTML = '<i class="material-icons" style="font-size:24px;">folder</i>';
|
||||
} else {
|
||||
iconButton.innerHTML = '<i class="material-icons" style="font-size:24px;">insert_drive_file</i>';
|
||||
}
|
||||
|
||||
// Save a reference to the card in the icon button.
|
||||
iconButton.cardElement = card;
|
||||
// Associate this icon with the card for future removal.
|
||||
card.headerIconButton = iconButton;
|
||||
|
||||
let modal = null;
|
||||
let isLocked = false;
|
||||
let hoverActive = false;
|
||||
|
||||
// showModal: When triggered, ensure the card is attached to the modal.
|
||||
function showModal() {
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'header-card-modal';
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.top = '55px';
|
||||
modal.style.right = '80px';
|
||||
modal.style.zIndex = '11000';
|
||||
// Render the modal but initially keep it hidden.
|
||||
modal.style.display = 'block';
|
||||
modal.style.visibility = 'hidden';
|
||||
modal.style.opacity = '0';
|
||||
modal.style.background = 'none';
|
||||
modal.style.border = 'none';
|
||||
modal.style.padding = '0';
|
||||
modal.style.boxShadow = 'none';
|
||||
document.body.appendChild(modal);
|
||||
// Attach modal hover events.
|
||||
modal.addEventListener('mouseover', handleMouseOver);
|
||||
modal.addEventListener('mouseout', handleMouseOut);
|
||||
iconButton.modalInstance = modal;
|
||||
}
|
||||
// If the card isn't already in the modal, remove it from the hidden container and attach it.
|
||||
if (!modal.contains(card)) {
|
||||
const hiddenContainer = document.getElementById('hiddenCardsContainer');
|
||||
if (hiddenContainer && hiddenContainer.contains(card)) {
|
||||
hiddenContainer.removeChild(card);
|
||||
}
|
||||
modal.appendChild(card);
|
||||
}
|
||||
// Reveal the modal.
|
||||
modal.style.visibility = 'visible';
|
||||
modal.style.opacity = '1';
|
||||
}
|
||||
|
||||
// hideModal: Hide the modal and return the card to the hidden container.
|
||||
function hideModal() {
|
||||
if (modal && !isLocked && !hoverActive) {
|
||||
modal.style.visibility = 'hidden';
|
||||
modal.style.opacity = '0';
|
||||
// Return the card to the hidden container.
|
||||
const hiddenContainer = document.getElementById('hiddenCardsContainer');
|
||||
if (hiddenContainer && modal.contains(card)) {
|
||||
hiddenContainer.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseOver() {
|
||||
hoverActive = true;
|
||||
showModal();
|
||||
}
|
||||
|
||||
function handleMouseOut() {
|
||||
hoverActive = false;
|
||||
setTimeout(() => {
|
||||
if (!hoverActive && !isLocked) {
|
||||
hideModal();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Attach hover events to the icon.
|
||||
iconButton.addEventListener('mouseover', handleMouseOver);
|
||||
iconButton.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
// Toggle the locked state on click so the modal stays open.
|
||||
iconButton.addEventListener('click', (e) => {
|
||||
isLocked = !isLocked;
|
||||
if (isLocked) {
|
||||
showModal();
|
||||
} else {
|
||||
hideModal();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Append the header icon button into the header drop zone.
|
||||
headerDropArea.appendChild(iconButton);
|
||||
// Save the updated header order.
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
// === Main Drag and Drop Initialization ===
|
||||
export function initDragAndDrop() {
|
||||
function run() {
|
||||
const draggableCards = document.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
draggableCards.forEach(card => {
|
||||
if (!card.dataset.originalContainerId) {
|
||||
card.dataset.originalContainerId = card.parentNode.id;
|
||||
}
|
||||
const header = card.querySelector('.card-header');
|
||||
if (header) {
|
||||
header.classList.add('drag-header');
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let dragTimer = null;
|
||||
let offsetX = 0, offsetY = 0;
|
||||
let initialLeft, initialTop;
|
||||
|
||||
if (header) {
|
||||
header.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
const card = this.closest('.card');
|
||||
// Capture the card's initial bounding rectangle.
|
||||
const initialRect = card.getBoundingClientRect();
|
||||
const originX = ((e.clientX - initialRect.left) / initialRect.width) * 100;
|
||||
const originY = ((e.clientY - initialRect.top) / initialRect.height) * 100;
|
||||
card.style.transformOrigin = `${originX}% ${originY}%`;
|
||||
|
||||
// Store the initial rect so we use it later.
|
||||
dragTimer = setTimeout(() => {
|
||||
isDragging = true;
|
||||
card.classList.add('dragging');
|
||||
card.style.pointerEvents = 'none';
|
||||
addTopZoneHighlight();
|
||||
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('active');
|
||||
sidebar.style.display = 'block';
|
||||
sidebar.classList.add('highlight');
|
||||
sidebar.style.height = '800px';
|
||||
}
|
||||
|
||||
// Show header drop zone while dragging.
|
||||
showHeaderDropZone();
|
||||
|
||||
// Use the stored initialRect.
|
||||
initialLeft = initialRect.left + window.pageXOffset;
|
||||
initialTop = initialRect.top + window.pageYOffset;
|
||||
offsetX = e.pageX - initialLeft;
|
||||
offsetY = e.pageY - initialTop;
|
||||
|
||||
// Remove any associated header icon if present.
|
||||
if (card.headerIconButton) {
|
||||
if (card.headerIconButton.parentNode) {
|
||||
card.headerIconButton.parentNode.removeChild(card.headerIconButton);
|
||||
}
|
||||
if (card.headerIconButton.modalInstance && card.headerIconButton.modalInstance.parentNode) {
|
||||
card.headerIconButton.modalInstance.parentNode.removeChild(card.headerIconButton.modalInstance);
|
||||
}
|
||||
card.headerIconButton = null;
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
// Append card to body and fix its dimensions.
|
||||
document.body.appendChild(card);
|
||||
card.style.position = 'absolute';
|
||||
card.style.left = initialLeft + 'px';
|
||||
card.style.top = initialTop + 'px';
|
||||
card.style.width = initialRect.width + 'px';
|
||||
card.style.height = initialRect.height + 'px';
|
||||
card.style.minWidth = initialRect.width + 'px';
|
||||
card.style.flexShrink = '0';
|
||||
card.style.zIndex = '10000';
|
||||
}, 500);
|
||||
});
|
||||
header.addEventListener('mouseup', function () {
|
||||
clearTimeout(dragTimer);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (isDragging) {
|
||||
card.style.left = (e.pageX - offsetX) + 'px';
|
||||
card.style.top = (e.pageY - offsetY) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function (e) {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
card.style.pointerEvents = '';
|
||||
card.classList.remove('dragging');
|
||||
removeTopZoneHighlight();
|
||||
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('highlight');
|
||||
sidebar.style.height = '';
|
||||
}
|
||||
|
||||
// Remove any existing header icon if present.
|
||||
if (card.headerIconButton) {
|
||||
if (card.headerIconButton.parentNode) {
|
||||
card.headerIconButton.parentNode.removeChild(card.headerIconButton);
|
||||
}
|
||||
if (card.headerIconButton.modalInstance && card.headerIconButton.modalInstance.parentNode) {
|
||||
card.headerIconButton.modalInstance.parentNode.removeChild(card.headerIconButton.modalInstance);
|
||||
}
|
||||
card.headerIconButton = null;
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
let droppedInSidebar = false;
|
||||
let droppedInTop = false;
|
||||
let droppedInHeader = false;
|
||||
|
||||
// Check if dropped in sidebar drop zone.
|
||||
const sidebarElem = document.getElementById('sidebarDropArea');
|
||||
if (sidebarElem) {
|
||||
const rect = sidebarElem.getBoundingClientRect();
|
||||
const dropZoneBottom = rect.top + 800; // Virtual drop zone height.
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= dropZoneBottom
|
||||
) {
|
||||
insertCardInSidebar(card, e);
|
||||
droppedInSidebar = true;
|
||||
}
|
||||
}
|
||||
// Check the top drop zone.
|
||||
const topRow = document.getElementById('uploadFolderRow');
|
||||
if (!droppedInSidebar && topRow) {
|
||||
const rect = topRow.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
let container;
|
||||
if (card.id === 'uploadCard') {
|
||||
container = document.getElementById('leftCol');
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
container = document.getElementById('rightCol');
|
||||
}
|
||||
if (container) {
|
||||
ensureTopZonePlaceholder();
|
||||
updateTopZoneLayout();
|
||||
container.appendChild(card);
|
||||
droppedInTop = true;
|
||||
// Set a fixed width during animation.
|
||||
card.style.width = "363px";
|
||||
animateVerticalSlide(card);
|
||||
setTimeout(() => {
|
||||
card.style.removeProperty('width');
|
||||
}, 210);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check the header drop zone.
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (!droppedInSidebar && !droppedInTop && headerDropArea) {
|
||||
const rect = headerDropArea.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
insertCardInHeader(card, e);
|
||||
droppedInHeader = true;
|
||||
}
|
||||
}
|
||||
// If card was not dropped in any zone, return it to its original container.
|
||||
if (!droppedInSidebar && !droppedInTop && !droppedInHeader) {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
card.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear inline drag-related styles.
|
||||
[
|
||||
'position',
|
||||
'left',
|
||||
'top',
|
||||
'z-index',
|
||||
'height',
|
||||
'min-width',
|
||||
'flex-shrink',
|
||||
'transition',
|
||||
'transform',
|
||||
'opacity'
|
||||
].forEach(prop => card.style.removeProperty(prop));
|
||||
|
||||
// For sidebar drops, force width to 100%.
|
||||
if (droppedInSidebar) {
|
||||
card.style.width = '100%';
|
||||
}
|
||||
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
|
||||
// Hide header drop zone if no icon is present.
|
||||
hideHeaderDropZone();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
576
js/fileActions.js
Normal file
@@ -0,0 +1,576 @@
|
||||
// fileActions.js
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { formatFolderName } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function handleDeleteSelected(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox:checked");
|
||||
if (checkboxes.length === 0) {
|
||||
showToast("no_files_selected");
|
||||
return;
|
||||
}
|
||||
|
||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||
const count = window.filesToDelete.length;
|
||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||
document.getElementById("deleteFilesModal").style.display = "block";
|
||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||
if (cancelDelete) {
|
||||
cancelDelete.addEventListener("click", function () {
|
||||
document.getElementById("deleteFilesModal").style.display = "none";
|
||||
window.filesToDelete = [];
|
||||
});
|
||||
}
|
||||
|
||||
const confirmDelete = document.getElementById("confirmDeleteFiles");
|
||||
if (confirmDelete) {
|
||||
confirmDelete.addEventListener("click", function () {
|
||||
fetch("deleteFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ folder: window.currentFolder, files: window.filesToDelete })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Selected files deleted successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not delete files"));
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error deleting files:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("deleteFilesModal").style.display = "none";
|
||||
window.filesToDelete = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
attachEnterKeyListener("downloadZipModal", "confirmDownloadZip");
|
||||
export function handleDownloadZipSelected(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox:checked");
|
||||
if (checkboxes.length === 0) {
|
||||
showToast("No files selected for download.");
|
||||
return;
|
||||
}
|
||||
window.filesToDownload = Array.from(checkboxes).map(chk => chk.value);
|
||||
document.getElementById("downloadZipModal").style.display = "block";
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("zipFileNameInput");
|
||||
input.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
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();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
export function confirmSingleDownload() {
|
||||
// Get the file name from the modal. Users can change it if desired.
|
||||
let fileName = document.getElementById("downloadFileNameInput").value.trim();
|
||||
if (!fileName) {
|
||||
showToast("Please enter a name for the file.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the download 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.
|
||||
const folder = window.currentFolder || "root";
|
||||
const downloadURL = "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);
|
||||
});
|
||||
}
|
||||
|
||||
export function handleExtractZipSelected(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox:checked");
|
||||
if (!checkboxes.length) {
|
||||
showToast("No files selected.");
|
||||
return;
|
||||
}
|
||||
const zipFiles = Array.from(checkboxes)
|
||||
.map(chk => chk.value)
|
||||
.filter(name => name.toLowerCase().endsWith(".zip"));
|
||||
if (!zipFiles.length) {
|
||||
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";
|
||||
|
||||
fetch("extractZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: window.currentFolder || "root",
|
||||
files: zipFiles
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Hide the progress modal once the request has completed.
|
||||
document.getElementById("downloadProgressModal").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(", ");
|
||||
}
|
||||
showToast(toastMessage);
|
||||
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";
|
||||
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";
|
||||
});
|
||||
}
|
||||
|
||||
// This part remains in your confirmDownloadZip event handler:
|
||||
const confirmDownloadZip = document.getElementById("confirmDownloadZip");
|
||||
if (confirmDownloadZip) {
|
||||
confirmDownloadZip.addEventListener("click", function () {
|
||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||
if (!zipName) {
|
||||
showToast("Please enter a name for the zip file.");
|
||||
return;
|
||||
}
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function handleCopySelected(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox:checked");
|
||||
if (checkboxes.length === 0) {
|
||||
showToast("No files selected for copying.", 5000);
|
||||
return;
|
||||
}
|
||||
window.filesToCopy = Array.from(checkboxes).map(chk => chk.value);
|
||||
document.getElementById("copyFilesModal").style.display = "block";
|
||||
loadCopyMoveFolderListForModal("copyTargetFolder");
|
||||
}
|
||||
|
||||
export async function loadCopyMoveFolderListForModal(dropdownId) {
|
||||
const folderSelect = document.getElementById(dropdownId);
|
||||
folderSelect.innerHTML = "";
|
||||
|
||||
if (window.userFolderOnly) {
|
||||
const username = localStorage.getItem("username") || "root";
|
||||
try {
|
||||
const response = await fetch("getFolderList.php?restricted=1");
|
||||
let folders = await response.json();
|
||||
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
|
||||
folders = folders.map(item => item.folder);
|
||||
}
|
||||
folders = folders.filter(folder =>
|
||||
folder.toLowerCase() !== "trash" &&
|
||||
(folder === username || folder.indexOf(username + "/") === 0)
|
||||
);
|
||||
|
||||
const rootOption = document.createElement("option");
|
||||
rootOption.value = username;
|
||||
rootOption.textContent = formatFolderName(username);
|
||||
folderSelect.appendChild(rootOption);
|
||||
|
||||
folders.forEach(folder => {
|
||||
if (folder !== username) {
|
||||
const option = document.createElement("option");
|
||||
option.value = folder;
|
||||
option.textContent = formatFolderName(folder);
|
||||
folderSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading folder list for modal:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("getFolderList.php");
|
||||
let folders = await response.json();
|
||||
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
|
||||
folders = folders.map(item => item.folder);
|
||||
}
|
||||
folders = folders.filter(folder => folder !== "root" && folder.toLowerCase() !== "trash");
|
||||
|
||||
const rootOption = document.createElement("option");
|
||||
rootOption.value = "root";
|
||||
rootOption.textContent = "(Root)";
|
||||
folderSelect.appendChild(rootOption);
|
||||
|
||||
if (Array.isArray(folders) && folders.length > 0) {
|
||||
folders.forEach(folder => {
|
||||
const option = document.createElement("option");
|
||||
option.value = folder;
|
||||
option.textContent = folder;
|
||||
folderSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading folder list for modal:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMoveSelected(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox:checked");
|
||||
if (checkboxes.length === 0) {
|
||||
showToast("No files selected for moving.");
|
||||
return;
|
||||
}
|
||||
window.filesToMove = Array.from(checkboxes).map(chk => chk.value);
|
||||
document.getElementById("moveFilesModal").style.display = "block";
|
||||
loadCopyMoveFolderListForModal("moveTargetFolder");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cancelCopy = document.getElementById("cancelCopyFiles");
|
||||
if (cancelCopy) {
|
||||
cancelCopy.addEventListener("click", function () {
|
||||
document.getElementById("copyFilesModal").style.display = "none";
|
||||
window.filesToCopy = [];
|
||||
});
|
||||
}
|
||||
const confirmCopy = document.getElementById("confirmCopyFiles");
|
||||
if (confirmCopy) {
|
||||
confirmCopy.addEventListener("click", function () {
|
||||
const targetFolder = document.getElementById("copyTargetFolder").value;
|
||||
if (!targetFolder) {
|
||||
showToast("Please select a target folder for copying.", 5000);
|
||||
return;
|
||||
}
|
||||
if (targetFolder === window.currentFolder) {
|
||||
showToast("Error: Cannot copy files to the same folder.");
|
||||
return;
|
||||
}
|
||||
fetch("copyFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: window.currentFolder,
|
||||
files: window.filesToCopy,
|
||||
destination: targetFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Selected files copied successfully!", 5000);
|
||||
loadFileList(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error copying files:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("copyFilesModal").style.display = "none";
|
||||
window.filesToCopy = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cancelMove = document.getElementById("cancelMoveFiles");
|
||||
if (cancelMove) {
|
||||
cancelMove.addEventListener("click", function () {
|
||||
document.getElementById("moveFilesModal").style.display = "none";
|
||||
window.filesToMove = [];
|
||||
});
|
||||
}
|
||||
const confirmMove = document.getElementById("confirmMoveFiles");
|
||||
if (confirmMove) {
|
||||
confirmMove.addEventListener("click", function () {
|
||||
const targetFolder = document.getElementById("moveTargetFolder").value;
|
||||
if (!targetFolder) {
|
||||
showToast("Please select a target folder for moving.");
|
||||
return;
|
||||
}
|
||||
if (targetFolder === window.currentFolder) {
|
||||
showToast("Error: Cannot move files to the same folder.");
|
||||
return;
|
||||
}
|
||||
fetch("moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: window.currentFolder,
|
||||
files: window.filesToMove,
|
||||
destination: targetFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Selected files moved successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not move files"));
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error moving files:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("moveFilesModal").style.display = "none";
|
||||
window.filesToMove = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function renameFile(oldName, folder) {
|
||||
window.fileToRename = oldName;
|
||||
window.fileFolder = folder || window.currentFolder || "root";
|
||||
document.getElementById("newFileName").value = oldName;
|
||||
document.getElementById("renameFileModal").style.display = "block";
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("newFileName");
|
||||
input.focus();
|
||||
const lastDot = oldName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
input.setSelectionRange(0, lastDot);
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const cancelBtn = document.getElementById("cancelRenameFile");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", function () {
|
||||
document.getElementById("renameFileModal").style.display = "none";
|
||||
document.getElementById("newFileName").value = "";
|
||||
});
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById("submitRenameFile");
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener("click", function () {
|
||||
const newName = document.getElementById("newFileName").value.trim();
|
||||
if (!newName || newName === window.fileToRename) {
|
||||
document.getElementById("renameFileModal").style.display = "none";
|
||||
return;
|
||||
}
|
||||
const folderUsed = window.fileFolder;
|
||||
fetch("renameFile.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ folder: folderUsed, oldName: window.fileToRename, newName: newName })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("File renamed successfully!");
|
||||
loadFileList(folderUsed);
|
||||
} else {
|
||||
showToast("Error renaming file: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error renaming file:", error);
|
||||
showToast("Error renaming file");
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById("renameFileModal").style.display = "none";
|
||||
document.getElementById("newFileName").value = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Expose initFileActions so it can be called from fileManager.js
|
||||
export function initFileActions() {
|
||||
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
||||
if (deleteSelectedBtn) {
|
||||
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
||||
document.getElementById("deleteSelectedBtn").addEventListener("click", handleDeleteSelected);
|
||||
}
|
||||
const copySelectedBtn = document.getElementById("copySelectedBtn");
|
||||
if (copySelectedBtn) {
|
||||
copySelectedBtn.replaceWith(copySelectedBtn.cloneNode(true));
|
||||
document.getElementById("copySelectedBtn").addEventListener("click", handleCopySelected);
|
||||
}
|
||||
const moveSelectedBtn = document.getElementById("moveSelectedBtn");
|
||||
if (moveSelectedBtn) {
|
||||
moveSelectedBtn.replaceWith(moveSelectedBtn.cloneNode(true));
|
||||
document.getElementById("moveSelectedBtn").addEventListener("click", handleMoveSelected);
|
||||
}
|
||||
const downloadZipBtn = document.getElementById("downloadZipBtn");
|
||||
if (downloadZipBtn) {
|
||||
downloadZipBtn.replaceWith(downloadZipBtn.cloneNode(true));
|
||||
document.getElementById("downloadZipBtn").addEventListener("click", handleDownloadZipSelected);
|
||||
}
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
if (extractZipBtn) {
|
||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||
}
|
||||
}
|
||||
|
||||
window.renameFile = renameFile;
|
||||
125
js/fileDragDrop.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// fileDragDrop.js
|
||||
import { showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = event.currentTarget;
|
||||
let fileNames = [];
|
||||
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 1) {
|
||||
selectedCheckboxes.forEach(chk => {
|
||||
const parentRow = chk.closest("tr");
|
||||
if (parentRow) {
|
||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
||||
if (cell) {
|
||||
let rawName = cell.textContent.trim();
|
||||
const tagContainer = cell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
||||
if (fileNameCell) {
|
||||
let rawName = fileNameCell.textContent.trim();
|
||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileNames.length === 0) return;
|
||||
|
||||
const dragData = fileNames.length === 1
|
||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
||||
|
||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
|
||||
let dragImage = document.createElement("div");
|
||||
dragImage.style.display = "inline-flex";
|
||||
dragImage.style.width = "auto";
|
||||
dragImage.style.maxWidth = "fit-content";
|
||||
dragImage.style.padding = "6px 10px";
|
||||
dragImage.style.backgroundColor = "#333";
|
||||
dragImage.style.color = "#fff";
|
||||
dragImage.style.border = "1px solid #555";
|
||||
dragImage.style.borderRadius = "4px";
|
||||
dragImage.style.alignItems = "center";
|
||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "material-icons";
|
||||
icon.textContent = "insert_drive_file";
|
||||
icon.style.marginRight = "4px";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
||||
dragImage.appendChild(icon);
|
||||
dragImage.appendChild(label);
|
||||
|
||||
document.body.appendChild(dragImage);
|
||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(dragImage);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function folderDragOverHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add("drop-hover");
|
||||
}
|
||||
|
||||
export function folderDragLeaveHandler(event) {
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
}
|
||||
|
||||
export function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data");
|
||||
return;
|
||||
}
|
||||
if (!dragData || !dragData.fileName) return;
|
||||
fetch("moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: [dragData.fileName],
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving file via drop:", error);
|
||||
showToast("Error moving file.");
|
||||
});
|
||||
}
|
||||
179
js/fileEditor.js
Normal file
@@ -0,0 +1,179 @@
|
||||
// fileEditor.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
function getModeForFile(fileName) {
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
switch (ext) {
|
||||
case "css":
|
||||
return "css";
|
||||
case "json":
|
||||
return { name: "javascript", json: true };
|
||||
case "js":
|
||||
return "javascript";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "text/html";
|
||||
case "xml":
|
||||
return "xml";
|
||||
default:
|
||||
return "text/plain";
|
||||
}
|
||||
}
|
||||
export { getModeForFile };
|
||||
|
||||
function adjustEditorSize() {
|
||||
const modal = document.querySelector(".editor-modal");
|
||||
if (modal && window.currentEditor) {
|
||||
const headerHeight = 60; // adjust as needed
|
||||
const availableHeight = modal.clientHeight - headerHeight;
|
||||
window.currentEditor.setSize("100%", availableHeight + "px");
|
||||
}
|
||||
}
|
||||
export { adjustEditorSize };
|
||||
|
||||
function observeModalResize(modal) {
|
||||
if (!modal) return;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
adjustEditorSize();
|
||||
});
|
||||
resizeObserver.observe(modal);
|
||||
}
|
||||
export { observeModalResize };
|
||||
|
||||
export function editFile(fileName, folder) {
|
||||
let existingEditor = document.getElementById("editorContainer");
|
||||
if (existingEditor) {
|
||||
existingEditor.remove();
|
||||
}
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const folderPath = folderUsed === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
|
||||
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
return fetch(fileUrl);
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error! Status: " + response.status);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(content => {
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "editorContainer";
|
||||
modal.classList.add("modal", "editor-modal");
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3>
|
||||
<div class="editor-controls">
|
||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||
</div>
|
||||
<button id="closeEditorX" class="editor-close-btn">×</button>
|
||||
</div>
|
||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||
<div class="editor-footer">
|
||||
<button id="saveBtn" class="btn btn-primary">${t("save")}</button>
|
||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
const mode = getModeForFile(fileName);
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const theme = isDarkMode ? "material-darker" : "default";
|
||||
|
||||
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
|
||||
lineNumbers: true,
|
||||
mode: mode,
|
||||
theme: theme,
|
||||
viewportMargin: Infinity
|
||||
});
|
||||
|
||||
window.currentEditor = editor;
|
||||
|
||||
setTimeout(() => {
|
||||
adjustEditorSize();
|
||||
}, 50);
|
||||
|
||||
observeModalResize(modal);
|
||||
|
||||
let currentFontSize = 14;
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
|
||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||
saveFile(fileName, folderUsed);
|
||||
});
|
||||
|
||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
function updateEditorTheme() {
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
|
||||
}
|
||||
|
||||
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
|
||||
})
|
||||
.catch(error => console.error("Error loading file:", error));
|
||||
}
|
||||
|
||||
|
||||
export function saveFile(fileName, folder) {
|
||||
const editor = window.currentEditor;
|
||||
if (!editor) {
|
||||
console.error("Editor not found!");
|
||||
return;
|
||||
}
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const fileDataObj = {
|
||||
fileName: fileName,
|
||||
content: editor.getValue(),
|
||||
folder: folderUsed
|
||||
};
|
||||
fetch("saveFile.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify(fileDataObj)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
showToast(result.success || result.error);
|
||||
document.getElementById("editorContainer")?.remove();
|
||||
loadFileList(folderUsed);
|
||||
})
|
||||
.catch(error => console.error("Error saving file:", error));
|
||||
}
|
||||
553
js/fileListView.js
Normal file
@@ -0,0 +1,553 @@
|
||||
// fileListView.js
|
||||
import {
|
||||
escapeHTML,
|
||||
debounce,
|
||||
buildSearchAndPaginationControls,
|
||||
buildFileTableHeader,
|
||||
buildFileTableRow,
|
||||
buildBottomControls,
|
||||
updateFileActionButtons,
|
||||
showToast,
|
||||
updateRowHighlight,
|
||||
toggleRowSelection,
|
||||
attachEnterKeyListener
|
||||
} from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
import { bindFileListContextMenu } from './fileMenu.js';
|
||||
import { openDownloadModal } from './fileActions.js';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
||||
|
||||
export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
|
||||
window.itemsPerPage = window.itemsPerPage || 10;
|
||||
window.currentPage = window.currentPage || 1;
|
||||
window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery"
|
||||
|
||||
// Global flag for advanced search mode.
|
||||
window.advancedSearchEnabled = false;
|
||||
|
||||
/**
|
||||
* --- Helper Functions ---
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||||
*/
|
||||
function parseSizeToBytes(sizeStr) {
|
||||
if (!sizeStr) return 0;
|
||||
let s = sizeStr.trim();
|
||||
let value = parseFloat(s);
|
||||
let upper = s.toUpperCase();
|
||||
if (upper.includes("KB")) {
|
||||
value *= 1024;
|
||||
} else if (upper.includes("MB")) {
|
||||
value *= 1024 * 1024;
|
||||
} else if (upper.includes("GB")) {
|
||||
value *= 1024 * 1024 * 1024;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the total bytes as a human-readable string.
|
||||
*/
|
||||
function formatSize(totalBytes) {
|
||||
if (totalBytes < 1024) {
|
||||
return totalBytes + " Bytes";
|
||||
} else if (totalBytes < 1024 * 1024) {
|
||||
return (totalBytes / 1024).toFixed(2) + " KB";
|
||||
} else if (totalBytes < 1024 * 1024 * 1024) {
|
||||
return (totalBytes / (1024 * 1024)).toFixed(2) + " MB";
|
||||
} else {
|
||||
return (totalBytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the folder summary HTML using the filtered file list.
|
||||
*/
|
||||
function buildFolderSummary(filteredFiles) {
|
||||
const totalFiles = filteredFiles.length;
|
||||
const totalBytes = filteredFiles.reduce((sum, file) => {
|
||||
return sum + parseSizeToBytes(file.size);
|
||||
}, 0);
|
||||
const sizeStr = formatSize(totalBytes);
|
||||
return `<strong>${t('total_files')}:</strong> ${totalFiles} | <strong>${t('total_size')}:</strong> ${sizeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* --- Advanced Search Toggle ---
|
||||
* Toggles advanced search mode. When enabled, the search will include additional keys (e.g. "content").
|
||||
*/
|
||||
function toggleAdvancedSearch() {
|
||||
window.advancedSearchEnabled = !window.advancedSearchEnabled;
|
||||
const advancedBtn = document.getElementById("advancedSearchToggle");
|
||||
if (advancedBtn) {
|
||||
advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search";
|
||||
}
|
||||
// Re-run the file table rendering with updated search settings.
|
||||
renderFileTable(window.currentFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* --- Fuse.js Search Helper ---
|
||||
* Uses Fuse.js to perform a fuzzy search on fileData.
|
||||
* By default, searches over file name, uploader, and tag names.
|
||||
* When advanced search is enabled, it also includes the 'content' property.
|
||||
*/
|
||||
function searchFiles(searchTerm) {
|
||||
if (!searchTerm) return fileData;
|
||||
|
||||
// Define search keys.
|
||||
let keys = [
|
||||
{ name: 'name', weight: 0.1 },
|
||||
{ name: 'uploader', weight: 0.1 },
|
||||
{ name: 'tags.name', weight: 0.1 }
|
||||
];
|
||||
if (window.advancedSearchEnabled) {
|
||||
keys.push({ name: 'content', weight: 0.7 });
|
||||
}
|
||||
|
||||
const options = {
|
||||
keys: keys,
|
||||
threshold: 0.4,
|
||||
minMatchCharLength: 2,
|
||||
ignoreLocation: true
|
||||
};
|
||||
|
||||
const fuse = new Fuse(fileData, options);
|
||||
let results = fuse.search(searchTerm);
|
||||
return results.map(result => result.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* --- VIEW MODE TOGGLE BUTTON & Helpers ---
|
||||
*/
|
||||
export function createViewToggleButton() {
|
||||
let toggleBtn = document.getElementById("toggleViewBtn");
|
||||
if (!toggleBtn) {
|
||||
toggleBtn = document.createElement("button");
|
||||
toggleBtn.id = "toggleViewBtn";
|
||||
toggleBtn.classList.add("btn", "btn-toggleview");
|
||||
|
||||
// Set initial icon and tooltip based on current view mode.
|
||||
if (window.viewMode === "gallery") {
|
||||
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
|
||||
toggleBtn.title = t("switch_to_table_view");
|
||||
} else {
|
||||
toggleBtn.innerHTML = '<i class="material-icons">view_module</i>';
|
||||
toggleBtn.title = t("switch_to_gallery_view");
|
||||
}
|
||||
|
||||
// Insert the button before the last button in the header.
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
if (headerButtons && headerButtons.lastElementChild) {
|
||||
headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild);
|
||||
} else if (headerButtons) {
|
||||
headerButtons.appendChild(toggleBtn);
|
||||
}
|
||||
}
|
||||
|
||||
toggleBtn.onclick = () => {
|
||||
window.viewMode = window.viewMode === "gallery" ? "table" : "gallery";
|
||||
localStorage.setItem("viewMode", window.viewMode);
|
||||
loadFileList(window.currentFolder);
|
||||
if (window.viewMode === "gallery") {
|
||||
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
|
||||
toggleBtn.title = t("switch_to_table_view");
|
||||
} else {
|
||||
toggleBtn.innerHTML = '<i class="material-icons">view_module</i>';
|
||||
toggleBtn.title = t("switch_to_gallery_view");
|
||||
}
|
||||
};
|
||||
|
||||
return toggleBtn;
|
||||
}
|
||||
|
||||
export function formatFolderName(folder) {
|
||||
if (folder === "root") return "(Root)";
|
||||
return folder
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, char => char.toUpperCase());
|
||||
}
|
||||
|
||||
// Expose inline DOM helpers.
|
||||
window.toggleRowSelection = toggleRowSelection;
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
|
||||
/**
|
||||
* --- FILE LIST & VIEW RENDERING ---
|
||||
*/
|
||||
export function loadFileList(folderParam) {
|
||||
const folder = folderParam || "root";
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
|
||||
fileListContainer.style.visibility = "hidden";
|
||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||
|
||||
return fetch("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 = "logout.php";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
fileListContainer.innerHTML = ""; // Clear loading message.
|
||||
if (data.files && Object.keys(data.files).length > 0) {
|
||||
// If the returned "files" is an object instead of an array, transform it.
|
||||
if (!Array.isArray(data.files)) {
|
||||
data.files = Object.entries(data.files).map(([name, meta]) => {
|
||||
meta.name = name;
|
||||
return meta;
|
||||
});
|
||||
}
|
||||
// Process each file – add computed properties.
|
||||
data.files = data.files.map(file => {
|
||||
file.fullName = (file.path || file.name).trim().toLowerCase();
|
||||
file.editable = canEditFile(file.name);
|
||||
file.folder = folder;
|
||||
if (!file.type && /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
||||
file.type = "image";
|
||||
}
|
||||
// OPTIONAL: For text documents, preload content (if available from backend)
|
||||
// Example: if (/\.txt|html|md|js|css|json|xml$/i.test(file.name)) { file.content = file.content || ""; }
|
||||
return file;
|
||||
});
|
||||
fileData = data.files;
|
||||
|
||||
// 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);
|
||||
} 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.";
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.finally(() => {
|
||||
fileListContainer.style.visibility = "visible";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update renderFileTable so it writes its content into the provided container.
|
||||
*/
|
||||
export function renderFileTable(folder, container) {
|
||||
const fileListContent = container || document.getElementById("fileList");
|
||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
||||
let currentPage = window.currentPage || 1;
|
||||
|
||||
// Use Fuse.js search via our helper function.
|
||||
const filteredFiles = searchFiles(searchTerm);
|
||||
|
||||
const totalFiles = filteredFiles.length;
|
||||
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
|
||||
if (currentPage > totalPages) {
|
||||
currentPage = totalPages > 0 ? totalPages : 1;
|
||||
window.currentPage = currentPage;
|
||||
}
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
|
||||
// Build the top controls and append the advanced search toggle button.
|
||||
const topControlsHTML = buildSearchAndPaginationControls({
|
||||
currentPage,
|
||||
totalPages,
|
||||
searchTerm: window.currentSearchTerm || ""
|
||||
});
|
||||
|
||||
const combinedTopHTML = topControlsHTML;
|
||||
|
||||
let headerHTML = buildFileTableHeader(sortOrder);
|
||||
const startIndex = (currentPage - 1) * itemsPerPageSetting;
|
||||
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
|
||||
let rowsHTML = "<tbody>";
|
||||
if (totalFiles > 0) {
|
||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||
let rowHTML = buildFileTableRow(file, folderPath);
|
||||
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
||||
|
||||
let tagBadgesHTML = "";
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
tagBadgesHTML = '<div class="tag-badges" style="display:inline-block; margin-left:5px;">';
|
||||
file.tags.forEach(tag => {
|
||||
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
|
||||
});
|
||||
tagBadgesHTML += "</div>";
|
||||
}
|
||||
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 {
|
||||
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
||||
}
|
||||
rowsHTML += "</tbody></table>";
|
||||
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
|
||||
|
||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||
|
||||
createViewToggleButton();
|
||||
|
||||
// Setup event listeners.
|
||||
const newSearchInput = document.getElementById("searchInput");
|
||||
if (newSearchInput) {
|
||||
newSearchInput.addEventListener("input", debounce(function () {
|
||||
window.currentSearchTerm = newSearchInput.value;
|
||||
window.currentPage = 1;
|
||||
renderFileTable(folder, container);
|
||||
setTimeout(() => {
|
||||
const freshInput = document.getElementById("searchInput");
|
||||
if (freshInput) {
|
||||
freshInput.focus();
|
||||
const len = freshInput.value.length;
|
||||
freshInput.setSelectionRange(len, len);
|
||||
}
|
||||
}, 0);
|
||||
}, 300));
|
||||
}
|
||||
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
|
||||
cell.addEventListener("click", function () {
|
||||
const column = this.getAttribute("data-column");
|
||||
sortFiles(column, folder);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("#fileList .file-checkbox").forEach(checkbox => {
|
||||
checkbox.addEventListener("change", function (e) {
|
||||
updateRowHighlight(e.target);
|
||||
updateFileActionButtons();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".share-btn").forEach(btn => {
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const fileName = this.getAttribute("data-file");
|
||||
const file = fileData.find(f => f.name === fileName);
|
||||
if (file) {
|
||||
import('./filePreview.js').then(module => {
|
||||
module.openShareModal(file, folder);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
updateFileActionButtons();
|
||||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||||
row.setAttribute("draggable", "true");
|
||||
import('./fileDragDrop.js').then(module => {
|
||||
row.addEventListener("dragstart", module.fileDragStartHandler);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => e.stopPropagation());
|
||||
});
|
||||
bindFileListContextMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* Similarly, update renderGalleryView to accept an optional container.
|
||||
*/
|
||||
export function renderGalleryView(folder, container) {
|
||||
const fileListContent = container || document.getElementById("fileList");
|
||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||
// Use Fuse.js search for gallery view as well.
|
||||
const filteredFiles = searchFiles(searchTerm);
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
const gridStyle = "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; padding: 10px;";
|
||||
let galleryHTML = `<div class="gallery-container" style="${gridStyle}">`;
|
||||
filteredFiles.forEach((file) => {
|
||||
let thumbnail;
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
||||
thumbnail = `<img src="${folderPath + encodeURIComponent(file.name)}?t=${new Date().getTime()}" class="gallery-thumbnail" alt="${escapeHTML(file.name)}" style="max-width: 100%; max-height: 150px; 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>`;
|
||||
} else {
|
||||
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
|
||||
}
|
||||
let tagBadgesHTML = "";
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
|
||||
file.tags.forEach(tag => {
|
||||
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
|
||||
});
|
||||
tagBadgesHTML += `</div>`;
|
||||
}
|
||||
galleryHTML += `<div class="gallery-card" style="border: 1px solid #ccc; padding: 5px; text-align: center;">
|
||||
<div class="gallery-preview" style="cursor: pointer;" onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t=' + new Date().getTime(), '${file.name}')">
|
||||
${thumbnail}
|
||||
</div>
|
||||
<div class="gallery-info" style="margin-top: 5px;">
|
||||
<span class="gallery-file-name" style="display: block; white-space: normal; overflow-wrap: break-word; word-wrap: break-word;">${escapeHTML(file.name)}</span>
|
||||
${tagBadgesHTML}
|
||||
<div class="button-wrap" style="display: flex; justify-content: center; gap: 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>
|
||||
</div>`;
|
||||
});
|
||||
galleryHTML += "</div>";
|
||||
fileListContent.innerHTML = galleryHTML;
|
||||
createViewToggleButton();
|
||||
updateFileActionButtons();
|
||||
document.querySelectorAll(".share-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
const fileName = btn.getAttribute("data-file");
|
||||
const file = fileData.find(f => f.name === fileName);
|
||||
import('./filePreview.js').then(module => {
|
||||
module.openShareModal(file, folder);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sortFiles(column, folder) {
|
||||
if (sortOrder.column === column) {
|
||||
sortOrder.ascending = !sortOrder.ascending;
|
||||
} else {
|
||||
sortOrder.column = column;
|
||||
sortOrder.ascending = true;
|
||||
}
|
||||
fileData.sort((a, b) => {
|
||||
let valA = a[column] || "";
|
||||
let valB = b[column] || "";
|
||||
if (column === "modified" || column === "uploaded") {
|
||||
const parsedA = parseCustomDate(valA);
|
||||
const parsedB = parseCustomDate(valB);
|
||||
valA = parsedA;
|
||||
valB = parsedB;
|
||||
} else if (typeof valA === "string") {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
if (valA < valB) return sortOrder.ascending ? -1 : 1;
|
||||
if (valA > valB) return sortOrder.ascending ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
if (window.viewMode === "gallery") {
|
||||
renderGalleryView(folder);
|
||||
} else {
|
||||
renderFileTable(folder);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCustomDate(dateStr) {
|
||||
dateStr = dateStr.replace(/\s+/g, " ").trim();
|
||||
const parts = dateStr.split(" ");
|
||||
if (parts.length !== 2) {
|
||||
return new Date(dateStr).getTime();
|
||||
}
|
||||
const datePart = parts[0];
|
||||
const timePart = parts[1];
|
||||
const dateComponents = datePart.split("/");
|
||||
if (dateComponents.length !== 3) {
|
||||
return new Date(dateStr).getTime();
|
||||
}
|
||||
let month = parseInt(dateComponents[0], 10);
|
||||
let day = parseInt(dateComponents[1], 10);
|
||||
let year = parseInt(dateComponents[2], 10);
|
||||
if (year < 100) {
|
||||
year += 2000;
|
||||
}
|
||||
const timeRegex = /^(\d{1,2}):(\d{2})(AM|PM)$/i;
|
||||
const match = timePart.match(timeRegex);
|
||||
if (!match) {
|
||||
return new Date(dateStr).getTime();
|
||||
}
|
||||
let hour = parseInt(match[1], 10);
|
||||
const minute = parseInt(match[2], 10);
|
||||
const period = match[3].toUpperCase();
|
||||
if (period === "PM" && hour !== 12) {
|
||||
hour += 12;
|
||||
}
|
||||
if (period === "AM" && hour === 12) {
|
||||
hour = 0;
|
||||
}
|
||||
return new Date(year, month - 1, day, hour, minute).getTime();
|
||||
}
|
||||
|
||||
export function canEditFile(fileName) {
|
||||
const allowedExtensions = [
|
||||
"txt", "html", "htm", "css", "js", "json", "xml",
|
||||
"md", "py", "ini", "csv", "log", "conf", "config", "bat",
|
||||
"rtf", "doc", "docx"
|
||||
];
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
return allowedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
// Expose global functions for pagination and preview.
|
||||
window.changePage = function (newPage) {
|
||||
window.currentPage = newPage;
|
||||
renderFileTable(window.currentFolder);
|
||||
};
|
||||
window.changeItemsPerPage = function (newCount) {
|
||||
window.itemsPerPage = parseInt(newCount);
|
||||
window.currentPage = 1;
|
||||
renderFileTable(window.currentFolder);
|
||||
};
|
||||
|
||||
// fileListView.js (bottom)
|
||||
window.loadFileList = loadFileList;
|
||||
window.renderFileTable = renderFileTable;
|
||||
window.renderGalleryView = renderGalleryView;
|
||||
window.sortFiles = sortFiles;
|
||||
window.toggleAdvancedSearch = toggleAdvancedSearch;
|
||||
40
js/fileManager.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// fileManager.js
|
||||
import './fileListView.js';
|
||||
import './filePreview.js';
|
||||
import './fileEditor.js';
|
||||
import './fileDragDrop.js';
|
||||
import './fileMenu.js';
|
||||
import { initFileActions } from './fileActions.js';
|
||||
|
||||
// Initialize file action buttons.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initFileActions();
|
||||
});
|
||||
|
||||
// Attach folder drag-and-drop support for folder tree nodes.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll(".folder-option").forEach(el => {
|
||||
import('./fileDragDrop.js').then(module => {
|
||||
el.addEventListener("dragover", module.folderDragOverHandler);
|
||||
el.addEventListener("dragleave", module.folderDragLeaveHandler);
|
||||
el.addEventListener("drop", module.folderDropHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Global keydown listener for file deletion via Delete/Backspace.
|
||||
document.addEventListener("keydown", function(e) {
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 0) {
|
||||
e.preventDefault();
|
||||
import('./fileActions.js').then(module => {
|
||||
module.handleDeleteSelected(new Event("click"));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
156
js/fileMenu.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// fileMenu.js
|
||||
import { updateRowHighlight, showToast } from './domUtils.js';
|
||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js';
|
||||
import { previewFile } from './filePreview.js';
|
||||
import { editFile } from './fileEditor.js';
|
||||
import { canEditFile, fileData } from './fileListView.js';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function showFileContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("fileContextMenu");
|
||||
if (!menu) {
|
||||
menu = document.createElement("div");
|
||||
menu.id = "fileContextMenu";
|
||||
menu.style.position = "fixed";
|
||||
menu.style.backgroundColor = "#fff";
|
||||
menu.style.border = "1px solid #ccc";
|
||||
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
|
||||
menu.style.zIndex = "9999";
|
||||
menu.style.padding = "5px 0";
|
||||
menu.style.minWidth = "150px";
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
menu.innerHTML = "";
|
||||
menuItems.forEach(item => {
|
||||
let menuItem = document.createElement("div");
|
||||
menuItem.textContent = item.label;
|
||||
menuItem.style.padding = "5px 15px";
|
||||
menuItem.style.cursor = "pointer";
|
||||
menuItem.addEventListener("mouseover", () => {
|
||||
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
|
||||
});
|
||||
menuItem.addEventListener("mouseout", () => {
|
||||
menuItem.style.backgroundColor = "";
|
||||
});
|
||||
menuItem.addEventListener("click", () => {
|
||||
item.action();
|
||||
hideFileContextMenu();
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
if (menuRect.bottom > viewportHeight) {
|
||||
let newTop = viewportHeight - menuRect.height;
|
||||
if (newTop < 0) newTop = 0;
|
||||
menu.style.top = newTop + "px";
|
||||
}
|
||||
}
|
||||
|
||||
export function hideFileContextMenu() {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function fileListContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
|
||||
let row = e.target.closest("tr");
|
||||
if (row) {
|
||||
const checkbox = row.querySelector(".file-checkbox");
|
||||
if (checkbox && !checkbox.checked) {
|
||||
checkbox.checked = true;
|
||||
updateRowHighlight(checkbox);
|
||||
}
|
||||
}
|
||||
|
||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||
|
||||
let menuItems = [
|
||||
{ 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")); } },
|
||||
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
|
||||
];
|
||||
|
||||
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
|
||||
menuItems.push({
|
||||
label: t("extract_zip"),
|
||||
action: () => { handleExtractZipSelected(new Event("click")); }
|
||||
});
|
||||
}
|
||||
|
||||
if (selected.length > 1) {
|
||||
menuItems.push({
|
||||
label: t("tag_selected"),
|
||||
action: () => {
|
||||
const files = fileData.filter(f => selected.includes(f.name));
|
||||
openMultiTagModal(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (selected.length === 1) {
|
||||
const file = fileData.find(f => f.name === selected[0]);
|
||||
|
||||
menuItems.push({
|
||||
label: t("preview"),
|
||||
action: () => {
|
||||
const folder = window.currentFolder || "root";
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (canEditFile(file.name)) {
|
||||
menuItems.push({
|
||||
label: t("edit"),
|
||||
action: () => { editFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: t("rename"),
|
||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
label: t("tag_file"),
|
||||
action: () => { openTagModal(file); }
|
||||
});
|
||||
}
|
||||
|
||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
||||
}
|
||||
|
||||
export function bindFileListContextMenu() {
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
if (fileListContainer) {
|
||||
fileListContainer.oncontextmenu = fileListContextMenuHandler;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu && menu.style.display === "block") {
|
||||
hideFileContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Rebind context menu after file table render.
|
||||
(function() {
|
||||
const originalRenderFileTable = window.renderFileTable;
|
||||
window.renderFileTable = function(folder) {
|
||||
originalRenderFileTable(folder);
|
||||
bindFileListContextMenu();
|
||||
};
|
||||
})();
|
||||
270
js/filePreview.js
Normal file
@@ -0,0 +1,270 @@
|
||||
// filePreview.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { fileData } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function openShareModal(file, folder) {
|
||||
const existing = document.getElementById("shareModal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
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-header">
|
||||
<h3>${t("share_file")}: ${escapeHTML(file.name)}</h3>
|
||||
<span class="close-image-modal" id="closeShareModal" title="Close">×</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>
|
||||
<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;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
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("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
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
let shareEndpoint = document.querySelector('meta[name="share-url"]')
|
||||
? document.querySelector('meta[name="share-url"]').getAttribute('content')
|
||||
: (window.SHARE_URL || "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";
|
||||
} else {
|
||||
showToast("Error generating share link: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error generating share link:", err);
|
||||
showToast("Error generating share link.");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast("Link copied to clipboard!");
|
||||
});
|
||||
}
|
||||
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
let modal = document.getElementById("filePreviewModal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "filePreviewModal";
|
||||
Object.assign(modal.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: "1000"
|
||||
});
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
|
||||
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">×</span>
|
||||
<h4 class="image-modal-header"></h4>
|
||||
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
function closeModal() {
|
||||
const mediaElements = modal.querySelectorAll("video, audio");
|
||||
mediaElements.forEach(media => {
|
||||
media.pause();
|
||||
if (media.tagName.toLowerCase() !== 'video') {
|
||||
try {
|
||||
media.currentTime = 0;
|
||||
} catch(e) { }
|
||||
}
|
||||
});
|
||||
modal.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("closeFileModal").addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", function (e) {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
modal.querySelector("h4").textContent = fileName;
|
||||
const container = modal.querySelector(".file-preview-container");
|
||||
container.innerHTML = "";
|
||||
|
||||
const extension = fileName.split('.').pop().toLowerCase();
|
||||
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName);
|
||||
if (isImage) {
|
||||
const img = document.createElement("img");
|
||||
img.src = fileUrl;
|
||||
img.className = "image-modal-img";
|
||||
img.style.maxWidth = "80vw";
|
||||
img.style.maxHeight = "80vh";
|
||||
container.appendChild(img);
|
||||
|
||||
const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name));
|
||||
if (images.length > 1) {
|
||||
modal.galleryImages = images;
|
||||
modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName);
|
||||
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.textContent = "‹";
|
||||
prevBtn.className = "gallery-nav-btn";
|
||||
prevBtn.style.cssText = "position: absolute; top: 50%; left: 10px; transform: translateY(-50%); background: transparent; border: none; color: white; font-size: 48px; cursor: pointer;";
|
||||
prevBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
});
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.textContent = "›";
|
||||
nextBtn.className = "gallery-nav-btn";
|
||||
nextBtn.style.cssText = "position: absolute; top: 50%; right: 10px; transform: translateY(-50%); background: transparent; border: none; color: white; font-size: 48px; cursor: pointer;";
|
||||
nextBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
});
|
||||
container.appendChild(prevBtn);
|
||||
container.appendChild(nextBtn);
|
||||
}
|
||||
} else {
|
||||
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)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = fileUrl;
|
||||
video.controls = true;
|
||||
video.className = "image-modal-img";
|
||||
|
||||
const progressKey = 'videoProgress-' + fileUrl;
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
const savedTime = localStorage.getItem(progressKey);
|
||||
if (savedTime) {
|
||||
video.currentTime = parseFloat(savedTime);
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener("timeupdate", () => {
|
||||
localStorage.setItem(progressKey, video.currentTime);
|
||||
});
|
||||
|
||||
video.addEventListener("ended", () => {
|
||||
localStorage.removeItem(progressKey);
|
||||
});
|
||||
|
||||
container.appendChild(video);
|
||||
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = fileUrl;
|
||||
audio.controls = true;
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "80vw";
|
||||
container.appendChild(audio);
|
||||
} else {
|
||||
container.textContent = "Preview not available for this file type.";
|
||||
}
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
// Added to preserve the original functionality.
|
||||
export function displayFilePreview(file, container) {
|
||||
const actualFile = file.file || file;
|
||||
|
||||
// Validate that actualFile is indeed a File
|
||||
if (!(actualFile instanceof File)) {
|
||||
console.error("displayFilePreview called with an invalid file object");
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = "inline-block";
|
||||
|
||||
// Clear the container safely without using innerHTML
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
|
||||
const img = document.createElement("img");
|
||||
// Set the image source using a Blob URL (this is considered safe)
|
||||
img.src = URL.createObjectURL(actualFile);
|
||||
img.classList.add("file-preview-img");
|
||||
container.appendChild(img);
|
||||
} else {
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.classList.add("material-icons", "file-icon");
|
||||
iconSpan.textContent = "insert_drive_file";
|
||||
container.appendChild(iconSpan);
|
||||
}
|
||||
}
|
||||
|
||||
window.previewFile = previewFile;
|
||||
window.openShareModal = openShareModal;
|
||||
@@ -4,6 +4,7 @@
|
||||
// updating the file row display with tag badges,
|
||||
// filtering the file list by tag, and persisting tag data.
|
||||
import { escapeHTML } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function openTagModal(file) {
|
||||
// Create the modal element.
|
||||
@@ -13,14 +14,14 @@ export function openTagModal(file) {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="width: 400px; max-width:90vw;">
|
||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0;">Tag File: ${file.name}</h3>
|
||||
<h3 style="margin:0;">${t("tag_file")}: ${file.name}</h3>
|
||||
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="margin-top:10px;">
|
||||
<label for="tagNameInput">Tag Name:</label>
|
||||
<label for="tagNameInput">${t("tag_name")}</label>
|
||||
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
||||
<br><br>
|
||||
<label for="tagColorInput">Tag Color:</label>
|
||||
<label for="tagColorInput">${t("tag_name")}</label>
|
||||
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||
<br><br>
|
||||
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
||||
@@ -28,7 +29,7 @@ export function openTagModal(file) {
|
||||
</div>
|
||||
<br>
|
||||
<div style="text-align:right;">
|
||||
<button id="saveTagBtn" class="btn btn-primary">Save Tag</button>
|
||||
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button>
|
||||
</div>
|
||||
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
|
||||
<!-- Existing tags will be listed here -->
|
||||
@@ -304,7 +305,7 @@ if (localStorage.getItem('globalTags')) {
|
||||
|
||||
// New function to load global tags from the server's persistent JSON.
|
||||
export function loadGlobalTags() {
|
||||
fetch("metadata/createdTags.json", { credentials: "include" })
|
||||
fetch("getFileTag.php", { credentials: "include" })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
// If the file doesn't exist, assume there are no global tags.
|
||||
@@ -1,7 +1,9 @@
|
||||
// folderManager.js
|
||||
|
||||
import { loadFileList } from './fileManager.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
import { openFolderShareModal } from './folderShareModal.js';
|
||||
|
||||
/* ----------------------
|
||||
Helper Functions (Data/State)
|
||||
@@ -59,8 +61,9 @@ function getParentFolder(folder) {
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
|
||||
function renderBreadcrumb(normalizedFolder) {
|
||||
if (!normalizedFolder || normalizedFolder === "") return "";
|
||||
const parts = normalizedFolder.split("/");
|
||||
@@ -76,73 +79,110 @@ function renderBreadcrumb(normalizedFolder) {
|
||||
return breadcrumbItems.join('');
|
||||
}
|
||||
|
||||
function bindBreadcrumbEvents() {
|
||||
const breadcrumbLinks = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbLinks.forEach(link => {
|
||||
link.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
let folder = this.getAttribute("data-folder");
|
||||
window.currentFolder = folder;
|
||||
localStorage.setItem("lastOpenedFolder", folder);
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(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");
|
||||
loadFileList(folder);
|
||||
bindBreadcrumbEvents();
|
||||
});
|
||||
link.addEventListener("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.add("drop-hover");
|
||||
});
|
||||
link.addEventListener("dragleave", function (e) {
|
||||
this.classList.remove("drop-hover");
|
||||
});
|
||||
link.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove("drop-hover");
|
||||
const dropFolder = this.getAttribute("data-folder");
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
} catch (err) {
|
||||
console.error("Invalid drag data on breadcrumb:", err);
|
||||
return;
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving files via drop on breadcrumb:", error);
|
||||
showToast("Error moving files.");
|
||||
});
|
||||
});
|
||||
});
|
||||
// --- NEW: Breadcrumb Delegation Setup ---
|
||||
// bindBreadcrumbEvents(); removed in favor of delegation
|
||||
export function setupBreadcrumbDelegation() {
|
||||
const container = document.getElementById("fileListTitle");
|
||||
if (!container) {
|
||||
console.error("Breadcrumb container (fileListTitle) not found.");
|
||||
return;
|
||||
}
|
||||
// Remove any existing event listeners to avoid duplicates.
|
||||
container.removeEventListener("click", breadcrumbClickHandler);
|
||||
container.removeEventListener("dragover", breadcrumbDragOverHandler);
|
||||
container.removeEventListener("dragleave", breadcrumbDragLeaveHandler);
|
||||
container.removeEventListener("drop", breadcrumbDropHandler);
|
||||
|
||||
// Attach delegated listeners
|
||||
container.addEventListener("click", breadcrumbClickHandler);
|
||||
container.addEventListener("dragover", breadcrumbDragOverHandler);
|
||||
container.addEventListener("dragleave", breadcrumbDragLeaveHandler);
|
||||
container.addEventListener("drop", breadcrumbDropHandler);
|
||||
}
|
||||
|
||||
// Click handler via delegation
|
||||
function breadcrumbClickHandler(e) {
|
||||
const link = e.target.closest(".breadcrumb-link");
|
||||
if (!link) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const folder = link.getAttribute("data-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 + ")";
|
||||
|
||||
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");
|
||||
loadFileList(folder);
|
||||
}
|
||||
|
||||
// Dragover handler via delegation
|
||||
function breadcrumbDragOverHandler(e) {
|
||||
const link = e.target.closest(".breadcrumb-link");
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
link.classList.add("drop-hover");
|
||||
}
|
||||
|
||||
// Dragleave handler via delegation
|
||||
function breadcrumbDragLeaveHandler(e) {
|
||||
const link = e.target.closest(".breadcrumb-link");
|
||||
if (!link) return;
|
||||
link.classList.remove("drop-hover");
|
||||
}
|
||||
|
||||
// Drop handler via delegation
|
||||
function breadcrumbDropHandler(e) {
|
||||
const link = e.target.closest(".breadcrumb-link");
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
link.classList.remove("drop-hover");
|
||||
const dropFolder = link.getAttribute("data-folder");
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
} catch (err) {
|
||||
console.error("Invalid drag data on breadcrumb:", err);
|
||||
return;
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving files via drop on breadcrumb:", error);
|
||||
showToast("Error moving files.");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------
|
||||
Check Current User's Folder-Only Permission
|
||||
----------------------*/
|
||||
@@ -379,8 +419,8 @@ export async function loadFolderTree(selectedFolder) {
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(window.currentFolder) + ")";
|
||||
bindBreadcrumbEvents();
|
||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
|
||||
setupBreadcrumbDelegation();
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const folderState = loadFolderTreeState();
|
||||
@@ -403,8 +443,8 @@ export async function loadFolderTree(selectedFolder) {
|
||||
window.currentFolder = selected;
|
||||
localStorage.setItem("lastOpenedFolder", selected);
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(selected) + ")";
|
||||
bindBreadcrumbEvents();
|
||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(selected) + ")";
|
||||
setupBreadcrumbDelegation();
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
@@ -696,18 +736,22 @@ function folderManagerContextMenuHandler(e) {
|
||||
target.classList.add("selected");
|
||||
const menuItems = [
|
||||
{
|
||||
label: "Create Folder",
|
||||
label: t("create_folder"),
|
||||
action: () => {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Rename Folder",
|
||||
label: t("rename_folder"),
|
||||
action: () => { openRenameFolderModal(); }
|
||||
},
|
||||
{
|
||||
label: "Delete Folder",
|
||||
label: t("folder_share"),
|
||||
action: () => { openFolderShareModal(); }
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => { openDeleteFolderModal(); }
|
||||
}
|
||||
];
|
||||
@@ -746,4 +790,21 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const shareFolderBtn = document.getElementById("shareFolderBtn");
|
||||
if (shareFolderBtn) {
|
||||
shareFolderBtn.addEventListener("click", () => {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
showToast("Please select a valid folder to share.");
|
||||
return;
|
||||
}
|
||||
// Call the folder share modal from the module.
|
||||
openFolderShareModal(selectedFolder);
|
||||
});
|
||||
} else {
|
||||
console.warn("shareFolderBtn element not found in the DOM.");
|
||||
}
|
||||
});
|
||||
|
||||
bindFolderManagerContextMenu();
|
||||
107
js/folderShareModal.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// folderShareModal.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function openFolderShareModal(folder) {
|
||||
// Remove any existing folder share modal
|
||||
const existing = document.getElementById("folderShareModal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Create the modal container
|
||||
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-header">
|
||||
<h3>${t("share_folder")}: ${escapeHTML(folder)}</h3>
|
||||
<span class="close-image-modal" id="closeFolderShareModal" title="Close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${t("set_expiration")}</p>
|
||||
<select id="folderShareExpiration">
|
||||
<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>
|
||||
</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")}
|
||||
</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;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
// Close button handler
|
||||
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("/createFolderShareLink.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
|
||||
})
|
||||
})
|
||||
.then(response => response.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";
|
||||
showToast(t("share_link_generated"));
|
||||
} else {
|
||||
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")));
|
||||
});
|
||||
});
|
||||
|
||||
// Copy share link button handler
|
||||
document.getElementById("copyFolderShareLinkBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("folderShareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
}
|
||||
962
js/i18n.js
Normal file
@@ -0,0 +1,962 @@
|
||||
/* i18n.js */
|
||||
const translations = {
|
||||
en: {
|
||||
"please_log_in_to_continue": "Please log in to continue.",
|
||||
"no_files_selected": "No files selected.",
|
||||
"confirm_delete_files": "Are you sure you want to delete {count} selected file(s)?",
|
||||
"element_not_found": "Element with id \"{id}\" not found.",
|
||||
"search_placeholder": "Search files, tags, & uploader...",
|
||||
"search_placeholder_advanced": "Advanced Search: files, tags, uploader & content...",
|
||||
"basic_search_tooltip": "Basic Search: Search by file name, tags, and uploader.",
|
||||
"advanced_search_tooltip": "Advanced Search: Includes file content, in addition to file name, tags, and uploader.",
|
||||
"file_name": "File Name",
|
||||
"date_modified": "Date Modified",
|
||||
"upload_date": "Upload Date",
|
||||
"file_size": "File Size",
|
||||
"uploader": "Uploader",
|
||||
"enter_totp_code": "Enter TOTP Code",
|
||||
"use_recovery_code_instead": "Use Recovery Code instead",
|
||||
"enter_recovery_code": "Enter Recovery Code",
|
||||
"editing": "Editing",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"no_files_found": "No files found.",
|
||||
"switch_to_table_view": "Switch to Table View",
|
||||
"switch_to_gallery_view": "Switch to Gallery View",
|
||||
"share_file": "Share File",
|
||||
"set_expiration": "Set Expiration:",
|
||||
"password_optional": "Password (optional):",
|
||||
"generate_share_link": "Generate Share Link",
|
||||
"shareable_link": "Shareable Link:",
|
||||
"copy_link": "Copy Link",
|
||||
"tag_file": "Tag File",
|
||||
"tag_name": "Tag Name:",
|
||||
"tag_color": "Tag Color:",
|
||||
"save_tag": "Save Tag",
|
||||
"light_mode": "Light Mode",
|
||||
"dark_mode": "Dark Mode",
|
||||
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
||||
"no_files_selected_default": "No files selected",
|
||||
"choose_files": "Choose files",
|
||||
"delete_selected": "Delete Selected",
|
||||
"copy_selected": "Copy Selected",
|
||||
"move_selected": "Move Selected",
|
||||
"tag_selected": "Tag Selected",
|
||||
"download_zip": "Download Zip",
|
||||
"extract_zip": "Extract Zip",
|
||||
"preview": "Preview",
|
||||
"edit": "Edit",
|
||||
"rename": "Rename",
|
||||
"trash_empty": "Trash is empty.",
|
||||
"no_trash_selected": "No trash items selected for restore.",
|
||||
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"logout": "Logout",
|
||||
"change_password": "Change Password",
|
||||
"restore_text": "Restore or",
|
||||
"delete_text": "Delete Trash Items",
|
||||
"restore_selected": "Restore Selected",
|
||||
"restore_all": "Restore All",
|
||||
"delete_selected_trash": "Delete Selected",
|
||||
"delete_all": "Delete All",
|
||||
"upload_header": "Upload Files/Folders",
|
||||
|
||||
// Folder Management keys:
|
||||
"folder_navigation": "Folder Navigation & Management",
|
||||
"create_folder": "Create Folder",
|
||||
"create_folder_title": "Create Folder",
|
||||
"enter_folder_name": "Enter folder name",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"rename_folder": "Rename Folder",
|
||||
"rename_folder_title": "Rename Folder",
|
||||
"rename_folder_placeholder": "Enter new folder name",
|
||||
"delete_folder": "Delete Folder",
|
||||
"delete_folder_title": "Delete Folder",
|
||||
"delete_folder_message": "Are you sure you want to delete this folder?",
|
||||
"folder_help": "Folder Help",
|
||||
"folder_help_item_1": "Click on a folder in the tree to view its files.",
|
||||
"folder_help_item_2": "Use [-] to collapse and [+] to expand folders.",
|
||||
"folder_help_item_3": "Select a folder and click \"Create Folder\" to add a subfolder.",
|
||||
"folder_help_item_4": "To rename or delete a folder, select it and then click the appropriate button.",
|
||||
|
||||
// File List keys:
|
||||
"actions": "Actions",
|
||||
"file_list_title": "Files in (Root)",
|
||||
"files_in": "Files in",
|
||||
"delete_files": "Delete Files",
|
||||
"delete_selected_files_title": "Delete Selected Files",
|
||||
"delete_files_message": "Are you sure you want to delete the selected files?",
|
||||
"copy_files": "Copy Files",
|
||||
"copy_files_title": "Copy Selected Files",
|
||||
"copy_files_message": "Select a target folder for copying the selected files:",
|
||||
"move_files": "Move Files",
|
||||
"move_files_title": "Move Selected Files",
|
||||
"move_files_message": "Select a target folder for moving the selected files:",
|
||||
"move": "Move",
|
||||
"extract_zip_button": "Extract Zip",
|
||||
"download_zip_title": "Download Selected Files as Zip",
|
||||
"download_zip_prompt": "Enter a name for the zip file:",
|
||||
"zip_placeholder": "files.zip",
|
||||
"share": "Share",
|
||||
"total_files": "Total Files",
|
||||
"total_size": "Total Size",
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"page": "Page",
|
||||
"of": "of",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Login",
|
||||
"remember_me": "Remember me",
|
||||
"login_oidc": "Login with OIDC",
|
||||
"basic_http_login": "Use Basic HTTP Login",
|
||||
|
||||
// Change Password keys:
|
||||
"change_password_title": "Change Password",
|
||||
"old_password": "Old Password",
|
||||
"new_password": "New Password",
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
|
||||
// Add User keys:
|
||||
"create_new_user_title": "Create New User",
|
||||
"username": "Username:",
|
||||
"password": "Password:",
|
||||
"enter_password": "Password",
|
||||
"preparing_download": "Preparing your download...",
|
||||
"download_file": "Download File",
|
||||
"confirm_or_change_filename": "Confirm or change the download file name:",
|
||||
"filename": "Filename",
|
||||
"cancel": "Cancel",
|
||||
"download": "Download",
|
||||
"grant_admin": "Grant Admin Access",
|
||||
"save_user": "Save User",
|
||||
|
||||
// Remove User keys:
|
||||
"remove_user_title": "Remove User",
|
||||
"select_user_remove": "Select a user to remove:",
|
||||
"delete_user": "Delete User",
|
||||
|
||||
// Rename File keys:
|
||||
"rename_file_title": "Rename File",
|
||||
"rename_file_placeholder": "Enter new file name",
|
||||
|
||||
// Folder Share
|
||||
"share_folder": "Share Folder",
|
||||
"allow_uploads": "Allow Uploads",
|
||||
"share_link_generated": "Share Link Generated",
|
||||
"error_generating_share_link": "Error Generating Share Link",
|
||||
|
||||
// Folder
|
||||
"folder_share": "Share Folder",
|
||||
|
||||
// Custom Confirm Modal keys:
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unsaved_changes_confirm": "You have unsaved changes. Are you sure you want to close without saving?",
|
||||
"delete": "Delete",
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"copy": "Copy",
|
||||
"extract": "Extract",
|
||||
"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",
|
||||
|
||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||
"admin_panel": "Admin Panel",
|
||||
"user_panel": "User Panel",
|
||||
"trash_restore_delete": "Trash Restore/Delete",
|
||||
"totp_settings": "TOTP Settings",
|
||||
"enable_totp": "Enable TOTP",
|
||||
"language": "Language",
|
||||
"select_language": "Select Language",
|
||||
"english": "English",
|
||||
"spanish": "Spanish",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"use_totp_code_instead": "Use TOTP Code instead",
|
||||
"submit_recovery_code": "Submit Recovery Code",
|
||||
"please_enter_recovery_code": "Please enter your recovery code.",
|
||||
"recovery_code_verification_failed": "Recovery code verification failed",
|
||||
"error_verifying_recovery_code": "Error verifying recovery code",
|
||||
"totp_verification_failed": "TOTP verification failed",
|
||||
"error_verifying_totp_code": "Error verifying TOTP code",
|
||||
"totp_setup": "TOTP Setup",
|
||||
"scan_qr_code": "Scan this QR code with your authenticator app.",
|
||||
"enter_totp_confirmation": "Enter the 6-digit code from your app to confirm setup:",
|
||||
"confirm": "Confirm",
|
||||
"please_enter_valid_code": "Please enter a valid 6-digit code.",
|
||||
"totp_enabled_successfully": "TOTP successfully enabled.",
|
||||
"error_generating_recovery_code": "Error generating recovery code",
|
||||
"error_loading_qr_code": "Error loading QR code.",
|
||||
"error_disabling_totp_setting": "Error disabling TOTP setting",
|
||||
"user_management": "User Management",
|
||||
"add_user": "Add User",
|
||||
"remove_user": "Remove User",
|
||||
"user_permissions": "User Permissions",
|
||||
"oidc_configuration": "OIDC Configuration",
|
||||
"oidc_provider_url": "OIDC Provider URL",
|
||||
"oidc_client_id": "OIDC Client ID",
|
||||
"oidc_client_secret": "OIDC Client Secret",
|
||||
"oidc_redirect_uri": "OIDC Redirect URI",
|
||||
"global_totp_settings": "Global TOTP Settings",
|
||||
"global_otpauth_url": "Global OTPAuth URL",
|
||||
"login_options": "Login Options",
|
||||
"disable_login_form": "Disable Login Form",
|
||||
"disable_basic_http_auth": "Disable Basic HTTP Auth",
|
||||
"disable_oidc_login": "Disable OIDC Login",
|
||||
"save_settings": "Save Settings",
|
||||
"at_least_one_login_method": "At least one login method must remain enabled.",
|
||||
"settings_updated_successfully": "Settings updated successfully.",
|
||||
"error_updating_settings": "Error updating settings",
|
||||
"user_permissions_updated_successfully": "User permissions updated successfully.",
|
||||
"error_updating_permissions": "Error updating permissions",
|
||||
"no_users_found": "No users found.",
|
||||
"user_folder_only": "User Folder Only",
|
||||
"read_only": "Read Only",
|
||||
"disable_upload": "Disable Upload",
|
||||
"error_loading_users": "Error loading users",
|
||||
"save_permissions": "Save Permissions",
|
||||
"your_recovery_code": "Your Recovery Code",
|
||||
"please_save_recovery_code": "Please save this code securely. It will not be shown again and can only be used once.",
|
||||
"ok": "OK",
|
||||
"show": "Show",
|
||||
"items_per_page": "items per page"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
"no_files_selected": "No se han seleccionado archivos.",
|
||||
"confirm_delete_files": "¿Está seguro de que desea eliminar {count} archivo(s) seleccionado(s)?",
|
||||
"element_not_found": "Elemento con id \"{id}\" no encontrado.",
|
||||
"search_placeholder": "Buscar archivos, etiquetas y cargador...",
|
||||
"search_placeholder_advanced": "Búsqueda avanzada: archivos, etiquetas, cargador y contenido...",
|
||||
"basic_search_tooltip": "Búsqueda básica: Buscar por nombre de archivo, etiquetas y cargador.",
|
||||
"advanced_search_tooltip": "Búsqueda avanzada: Incluye el contenido del archivo, además del nombre, etiquetas y cargador.",
|
||||
"file_name": "Nombre del archivo",
|
||||
"date_modified": "Fecha de modificación",
|
||||
"upload_date": "Fecha de carga",
|
||||
"file_size": "Tamaño del archivo",
|
||||
"uploader": "Cargado por",
|
||||
"enter_totp_code": "Ingrese el código TOTP",
|
||||
"use_recovery_code_instead": "Usar código de recuperación en su lugar",
|
||||
"enter_recovery_code": "Ingrese el código de recuperación",
|
||||
"editing": "Editando",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "Guardar",
|
||||
"close": "Cerrar",
|
||||
"no_files_found": "No se encontraron archivos.",
|
||||
"switch_to_table_view": "Cambiar a vista de tabla",
|
||||
"switch_to_gallery_view": "Cambiar a vista de galería",
|
||||
"share_file": "Compartir archivo",
|
||||
"set_expiration": "Establecer vencimiento:",
|
||||
"password_optional": "Contraseña (opcional):",
|
||||
"generate_share_link": "Generar enlace para compartir",
|
||||
"shareable_link": "Enlace para compartir:",
|
||||
"copy_link": "Copiar enlace",
|
||||
"tag_file": "Etiquetar archivo",
|
||||
"tag_name": "Nombre de la etiqueta:",
|
||||
"tag_color": "Color de la etiqueta:",
|
||||
"save_tag": "Guardar etiqueta",
|
||||
"light_mode": "Modo claro",
|
||||
"dark_mode": "Modo oscuro",
|
||||
"upload_instruction": "Suelte archivos/carpetas aquí o haga clic en 'Elegir archivos'",
|
||||
"no_files_selected_default": "No se han seleccionado archivos",
|
||||
"choose_files": "Elegir archivos",
|
||||
"delete_selected": "Eliminar seleccionados",
|
||||
"copy_selected": "Copiar seleccionados",
|
||||
"move_selected": "Mover seleccionados",
|
||||
"tag_selected": "Etiquetar seleccionados",
|
||||
"download_zip": "Descargar Zip",
|
||||
"extract_zip": "Extraer Zip",
|
||||
"preview": "Vista previa",
|
||||
"edit": "Editar",
|
||||
"rename": "Renombrar",
|
||||
"trash_empty": "La papelera está vacía.",
|
||||
"no_trash_selected": "No se han seleccionado elementos de la papelera para restaurar.",
|
||||
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"logout": "Cerrar sesión",
|
||||
"change_password": "Cambiar contraseña",
|
||||
"restore_text": "Restaurar o",
|
||||
"delete_text": "Eliminar elementos de la papelera",
|
||||
"restore_selected": "Restaurar seleccionados",
|
||||
"restore_all": "Restaurar todo",
|
||||
"delete_selected_trash": "Eliminar seleccionados",
|
||||
"delete_all": "Eliminar todo",
|
||||
"upload_header": "Cargar archivos/carpetas",
|
||||
|
||||
// Folder Management keys:
|
||||
"folder_navigation": "Navegación y gestión de carpetas",
|
||||
"create_folder": "Crear carpeta",
|
||||
"create_folder_title": "Crear carpeta",
|
||||
"enter_folder_name": "Ingrese el nombre de la carpeta",
|
||||
"cancel": "Cancelar",
|
||||
"create": "Crear",
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
"rename_folder_title": "Renombrar carpeta",
|
||||
"rename_folder_placeholder": "Ingrese el nuevo nombre de la carpeta",
|
||||
"delete_folder": "Eliminar carpeta",
|
||||
"delete_folder_title": "Eliminar carpeta",
|
||||
"delete_folder_message": "¿Está seguro de que desea eliminar esta carpeta?",
|
||||
"folder_help": "Ayuda de carpetas",
|
||||
"folder_help_item_1": "Haga clic en una carpeta en el árbol para ver sus archivos.",
|
||||
"folder_help_item_2": "Utilice [-] para contraer y [+] para expandir las carpetas.",
|
||||
"folder_help_item_3": "Seleccione una carpeta y haga clic en \"Crear carpeta\" para agregar una subcarpeta.",
|
||||
"folder_help_item_4": "Para renombrar o eliminar una carpeta, selecciónela y luego haga clic en el botón correspondiente.",
|
||||
|
||||
// File List keys:
|
||||
"file_list_title": "Archivos en (Raíz)",
|
||||
"files_in": "Archivos en",
|
||||
"delete_files": "Eliminar archivos",
|
||||
"delete_selected_files_title": "Eliminar archivos seleccionados",
|
||||
"delete_files_message": "¿Está seguro de que desea eliminar los archivos seleccionados?",
|
||||
"copy_files": "Copiar archivos",
|
||||
"copy_files_title": "Copiar archivos seleccionados",
|
||||
"copy_files_message": "Seleccione una carpeta destino para copiar los archivos seleccionados:",
|
||||
"move_files": "Mover archivos",
|
||||
"move_files_title": "Mover archivos seleccionados",
|
||||
"move_files_message": "Seleccione una carpeta destino para mover los archivos seleccionados:",
|
||||
"move": "Mover",
|
||||
"extract_zip_button": "Extraer Zip",
|
||||
"download_zip_title": "Descargar archivos seleccionados en un Zip",
|
||||
"download_zip_prompt": "Ingrese un nombre para el archivo Zip:",
|
||||
"zip_placeholder": "files.zip",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Iniciar sesión",
|
||||
"remember_me": "Recuérdame",
|
||||
"login_oidc": "Iniciar sesión con OIDC",
|
||||
"basic_http_login": "Usar autenticación HTTP básica",
|
||||
|
||||
// Change Password keys:
|
||||
"change_password_title": "Cambiar contraseña",
|
||||
"old_password": "Contraseña antigua",
|
||||
"new_password": "Nueva contraseña",
|
||||
"confirm_new_password": "Confirmar nueva contraseña",
|
||||
|
||||
// Add User keys:
|
||||
"create_new_user_title": "Crear nuevo usuario",
|
||||
"username": "Usuario:",
|
||||
"password": "Contraseña:",
|
||||
"enter_password": "Contraseña",
|
||||
"preparing_download": "Preparando su descarga...",
|
||||
"download_file": "Descargar Archivo",
|
||||
"confirm_or_change_filename": "Confirme o cambie el nombre del archivo a descargar:",
|
||||
"filename": "Nombre de archivo",
|
||||
"cancel": "Cancelar",
|
||||
"download": "Descargar",
|
||||
"grant_admin": "Otorgar acceso de administrador",
|
||||
"save_user": "Guardar usuario",
|
||||
|
||||
// Remove User keys:
|
||||
"remove_user_title": "Eliminar usuario",
|
||||
"select_user_remove": "Seleccione un usuario para eliminar:",
|
||||
"delete_user": "Eliminar usuario",
|
||||
|
||||
// Rename File keys:
|
||||
"rename_file_title": "Renombrar archivo",
|
||||
"rename_file_placeholder": "Ingrese el nuevo nombre del archivo",
|
||||
|
||||
// Folder Share
|
||||
"share_folder": "Compartir carpeta",
|
||||
"allow_uploads": "Permitir cargas",
|
||||
"share_link_generated": "Enlace para compartir generado",
|
||||
"error_generating_share_link": "Error al generar el enlace para compartir",
|
||||
|
||||
// Folder
|
||||
"folder_share": "Compartir carpeta",
|
||||
|
||||
// Custom Confirm Modal keys:
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"unsaved_changes_confirm": "Tiene cambios sin guardar. ¿Está seguro de que desea cerrar sin guardar?",
|
||||
"delete": "Eliminar",
|
||||
"download": "Descargar",
|
||||
"upload": "Cargar",
|
||||
"copy": "Copiar",
|
||||
"extract": "Extraer",
|
||||
"user": "Usuario:",
|
||||
"unknown_error": "Error desconocido",
|
||||
"link_copied": "Enlace copiado al portapapeles",
|
||||
"minutes": "minutos",
|
||||
"hours": "horas",
|
||||
"days": "días",
|
||||
"weeks": "semanas",
|
||||
"months": "meses",
|
||||
"seconds": "segundos",
|
||||
|
||||
// Dark Mode Toggle
|
||||
"dark_mode_toggle": "Modo oscuro",
|
||||
"light_mode_toggle": "Modo claro",
|
||||
|
||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||
"admin_panel": "Panel de Administración",
|
||||
"user_panel": "Panel de Usuario",
|
||||
"totp_settings": "Configuración TOTP",
|
||||
"enable_totp": "Activar TOTP",
|
||||
"language": "Idioma",
|
||||
"select_language": "Seleccionar idioma",
|
||||
"english": "Inglés",
|
||||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"german": "Alemán",
|
||||
"use_totp_code_instead": "Usar código TOTP en su lugar",
|
||||
"submit_recovery_code": "Enviar código de recuperación",
|
||||
"please_enter_recovery_code": "Por favor, ingrese su código de recuperación.",
|
||||
"recovery_code_verification_failed": "La verificación del código de recuperación falló",
|
||||
"error_verifying_recovery_code": "Error al verificar el código de recuperación",
|
||||
"totp_verification_failed": "La verificación TOTP falló",
|
||||
"error_verifying_totp_code": "Error al verificar el código TOTP",
|
||||
"totp_setup": "Configuración TOTP",
|
||||
"scan_qr_code": "Escanee este código QR con su aplicación de autenticación.",
|
||||
"enter_totp_confirmation": "Ingrese el código de 6 dígitos de su aplicación para confirmar la configuración:",
|
||||
"confirm": "Confirmar",
|
||||
"please_enter_valid_code": "Por favor, ingrese un código válido de 6 dígitos.",
|
||||
"totp_enabled_successfully": "TOTP activado con éxito.",
|
||||
"error_generating_recovery_code": "Error al generar el código de recuperación",
|
||||
"error_loading_qr_code": "Error al cargar el código QR.",
|
||||
"error_disabling_totp_setting": "Error al desactivar la configuración TOTP",
|
||||
"user_management": "Gestión de Usuarios",
|
||||
"add_user": "Agregar usuario",
|
||||
"remove_user": "Eliminar usuario",
|
||||
"user_permissions": "Permisos de Usuario",
|
||||
"oidc_configuration": "Configuración OIDC",
|
||||
"oidc_provider_url": "URL del Proveedor OIDC",
|
||||
"oidc_client_id": "ID del Cliente OIDC",
|
||||
"oidc_client_secret": "Secreto del Cliente OIDC",
|
||||
"oidc_redirect_uri": "URI de Redirección OIDC",
|
||||
"global_totp_settings": "Configuración Global TOTP",
|
||||
"global_otpauth_url": "URL Global OTPAuth",
|
||||
"login_options": "Opciones de inicio de sesión",
|
||||
"disable_login_form": "Desactivar formulario de inicio de sesión",
|
||||
"disable_basic_http_auth": "Desactivar autenticación HTTP básica",
|
||||
"disable_oidc_login": "Desactivar inicio de sesión OIDC",
|
||||
"save_settings": "Guardar configuración",
|
||||
"at_least_one_login_method": "Al menos un método de inicio de sesión debe permanecer habilitado.",
|
||||
"settings_updated_successfully": "Configuración actualizada con éxito.",
|
||||
"error_updating_settings": "Error al actualizar la configuración",
|
||||
"user_permissions_updated_successfully": "Permisos de usuario actualizados con éxito.",
|
||||
"error_updating_permissions": "Error al actualizar los permisos",
|
||||
"no_users_found": "No se encontraron usuarios.",
|
||||
"user_folder_only": "Solo carpeta de usuario",
|
||||
"read_only": "Solo lectura",
|
||||
"disable_upload": "Desactivar carga",
|
||||
"error_loading_users": "Error al cargar usuarios",
|
||||
"save_permissions": "Guardar permisos",
|
||||
"your_recovery_code": "Su código de recuperación",
|
||||
"please_save_recovery_code": "Por favor, guarde este código de forma segura. No se mostrará de nuevo y solo podrá usarse una vez.",
|
||||
"ok": "OK"
|
||||
},
|
||||
fr: {
|
||||
"please_log_in_to_continue": "Veuillez vous connecter pour continuer.",
|
||||
"no_files_selected": "Aucun fichier sélectionné.",
|
||||
"confirm_delete_files": "Êtes-vous sûr de vouloir supprimer {count} fichier(s) sélectionné(s) ?",
|
||||
"element_not_found": "Élément avec l'id \"{id}\" non trouvé.",
|
||||
"search_placeholder": "Rechercher des fichiers, des balises et l'uploader...",
|
||||
"search_placeholder_advanced": "Recherche avancée : fichiers, balises, uploader et contenu...",
|
||||
"basic_search_tooltip": "Recherche basique : rechercher par nom de fichier, balises et uploader.",
|
||||
"advanced_search_tooltip": "Recherche avancée : inclut le contenu du fichier, en plus du nom, des balises et de l'uploader.",
|
||||
"file_name": "Nom du fichier",
|
||||
"date_modified": "Date de modification",
|
||||
"upload_date": "Date de téléchargement",
|
||||
"file_size": "Taille du fichier",
|
||||
"uploader": "Uploader",
|
||||
"enter_totp_code": "Entrez le code TOTP",
|
||||
"use_recovery_code_instead": "Utilisez le code de récupération à la place",
|
||||
"enter_recovery_code": "Entrez le code de récupération",
|
||||
"editing": "Modification",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "Enregistrer",
|
||||
"close": "Fermer",
|
||||
"no_files_found": "Aucun fichier trouvé.",
|
||||
"switch_to_table_view": "Passer en vue tableau",
|
||||
"switch_to_gallery_view": "Passer en vue galerie",
|
||||
"share_file": "Partager le fichier",
|
||||
"set_expiration": "Définir l'expiration :",
|
||||
"password_optional": "Mot de passe (facultatif) :",
|
||||
"generate_share_link": "Générer le lien de partage",
|
||||
"shareable_link": "Lien partageable :",
|
||||
"copy_link": "Copier le lien",
|
||||
"tag_file": "Étiqueter le fichier",
|
||||
"tag_name": "Nom de l'étiquette :",
|
||||
"tag_color": "Couleur de l'étiquette :",
|
||||
"save_tag": "Enregistrer l'étiquette",
|
||||
"light_mode": "Mode clair",
|
||||
"dark_mode": "Mode sombre",
|
||||
"upload_instruction": "Déposez des fichiers/dossiers ici ou cliquez sur 'Choisir des fichiers'",
|
||||
"no_files_selected_default": "Aucun fichier sélectionné",
|
||||
"choose_files": "Choisir des fichiers",
|
||||
"delete_selected": "Supprimer la sélection",
|
||||
"copy_selected": "Copier la sélection",
|
||||
"move_selected": "Déplacer la sélection",
|
||||
"tag_selected": "Étiqueter la sélection",
|
||||
"download_zip": "Télécharger le Zip",
|
||||
"extract_zip": "Extraire le Zip",
|
||||
"preview": "Aperçu",
|
||||
"edit": "Modifier",
|
||||
"rename": "Renommer",
|
||||
"trash_empty": "La corbeille est vide.",
|
||||
"no_trash_selected": "Aucun élément de la corbeille sélectionné pour restauration.",
|
||||
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"logout": "Déconnexion",
|
||||
"change_password": "Changer le mot de passe",
|
||||
"restore_text": "Restaurer ou",
|
||||
"delete_text": "Supprimer les éléments de la corbeille",
|
||||
"restore_selected": "Restaurer la sélection",
|
||||
"restore_all": "Restaurer tout",
|
||||
"delete_selected_trash": "Supprimer la sélection",
|
||||
"delete_all": "Supprimer tout",
|
||||
"upload_header": "Téléverser des fichiers/dossiers",
|
||||
|
||||
// Folder Management keys:
|
||||
"folder_navigation": "Navigation et gestion des dossiers",
|
||||
"create_folder": "Créer un dossier",
|
||||
"create_folder_title": "Créer un dossier",
|
||||
"enter_folder_name": "Entrez le nom du dossier",
|
||||
"cancel": "Annuler",
|
||||
"create": "Créer",
|
||||
"rename_folder": "Renommer le dossier",
|
||||
"rename_folder_title": "Renommer le dossier",
|
||||
"rename_folder_placeholder": "Entrez le nouveau nom du dossier",
|
||||
"delete_folder": "Supprimer le dossier",
|
||||
"delete_folder_title": "Supprimer le dossier",
|
||||
"delete_folder_message": "Êtes-vous sûr de vouloir supprimer ce dossier ?",
|
||||
"folder_help": "Aide des dossiers",
|
||||
"folder_help_item_1": "Cliquez sur un dossier dans l'arborescence pour voir ses fichiers.",
|
||||
"folder_help_item_2": "Utilisez [-] pour réduire et [+] pour développer les dossiers.",
|
||||
"folder_help_item_3": "Sélectionnez un dossier et cliquez sur \"Créer un dossier\" pour ajouter un sous-dossier.",
|
||||
"folder_help_item_4": "Pour renommer ou supprimer un dossier, sélectionnez-le puis cliquez sur le bouton approprié.",
|
||||
|
||||
// File List keys:
|
||||
"file_list_title": "Fichiers dans (Racine)",
|
||||
"files_in": "Fichiers dans",
|
||||
"delete_files": "Supprimer les fichiers",
|
||||
"delete_selected_files_title": "Supprimer les fichiers sélectionnés",
|
||||
"delete_files_message": "Êtes-vous sûr de vouloir supprimer les fichiers sélectionnés ?",
|
||||
"copy_files": "Copier les fichiers",
|
||||
"copy_files_title": "Copier les fichiers sélectionnés",
|
||||
"copy_files_message": "Sélectionnez un dossier de destination pour copier les fichiers sélectionnés :",
|
||||
"move_files": "Déplacer les fichiers",
|
||||
"move_files_title": "Déplacer les fichiers sélectionnés",
|
||||
"move_files_message": "Sélectionnez un dossier de destination pour déplacer les fichiers sélectionnés :",
|
||||
"move": "Déplacer",
|
||||
"extract_zip_button": "Extraire le Zip",
|
||||
"download_zip_title": "Télécharger les fichiers sélectionnés en Zip",
|
||||
"download_zip_prompt": "Entrez un nom pour le fichier Zip :",
|
||||
"zip_placeholder": "files.zip",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Connexion",
|
||||
"remember_me": "Se souvenir de moi",
|
||||
"login_oidc": "Se connecter avec OIDC",
|
||||
"basic_http_login": "Utiliser l'authentification HTTP basique",
|
||||
|
||||
// Change Password keys:
|
||||
"change_password_title": "Changer le mot de passe",
|
||||
"old_password": "Ancien mot de passe",
|
||||
"new_password": "Nouveau mot de passe",
|
||||
"confirm_new_password": "Confirmer le nouveau mot de passe",
|
||||
|
||||
// Add User keys:
|
||||
"create_new_user_title": "Créer un nouvel utilisateur",
|
||||
"username": "Nom d'utilisateur :",
|
||||
"password": "Mot de passe :",
|
||||
"enter_password": "Mot de passe",
|
||||
"preparing_download": "Préparation de votre téléchargement...",
|
||||
"download_file": "Télécharger le fichier",
|
||||
"confirm_or_change_filename": "Confirmez ou modifiez le nom du fichier à télécharger :",
|
||||
"filename": "Nom du fichier",
|
||||
"cancel": "Annuler",
|
||||
"download": "Télécharger",
|
||||
"grant_admin": "Accorder l'accès administrateur",
|
||||
"save_user": "Enregistrer l'utilisateur",
|
||||
|
||||
// Remove User keys:
|
||||
"remove_user_title": "Supprimer un utilisateur",
|
||||
"select_user_remove": "Sélectionnez un utilisateur à supprimer :",
|
||||
"delete_user": "Supprimer l'utilisateur",
|
||||
|
||||
// Rename File keys:
|
||||
"rename_file_title": "Renommer le fichier",
|
||||
"rename_file_placeholder": "Entrez le nouveau nom du fichier",
|
||||
|
||||
// Folder Share
|
||||
"share_folder": "Partager le dossier",
|
||||
"allow_uploads": "Autoriser les téléchargements",
|
||||
"share_link_generated": "Lien de partage généré",
|
||||
"error_generating_share_link": "Erreur lors de la génération du lien de partage",
|
||||
|
||||
// Folder
|
||||
"folder_share": "Partager le dossier",
|
||||
|
||||
// Custom Confirm Modal keys:
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"unsaved_changes_confirm": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir fermer sans enregistrer ?",
|
||||
"delete": "Supprimer",
|
||||
"download": "Télécharger",
|
||||
"upload": "Téléverser",
|
||||
"copy": "Copier",
|
||||
"extract": "Extraire",
|
||||
"user": "Utilisateur :",
|
||||
"unknown_error": "Erreur inconnue",
|
||||
"link_copied": "Lien copié dans le presse-papiers",
|
||||
"minutes": "minutes",
|
||||
"hours": "heures",
|
||||
"days": "jours",
|
||||
"weeks": "semaines",
|
||||
"months": "mois",
|
||||
"seconds": "secondes",
|
||||
|
||||
// Dark Mode Toggle
|
||||
"dark_mode_toggle": "Mode sombre",
|
||||
"light_mode_toggle": "Mode clair",
|
||||
|
||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||
"admin_panel": "Panneau d'administration",
|
||||
"user_panel": "Panneau utilisateur",
|
||||
"totp_settings": "Paramètres TOTP",
|
||||
"enable_totp": "Activer TOTP",
|
||||
"language": "Langue",
|
||||
"select_language": "Sélectionnez la langue",
|
||||
"english": "Anglais",
|
||||
"spanish": "Espagnol",
|
||||
"french": "Français",
|
||||
"german": "Allemand",
|
||||
"use_totp_code_instead": "Utiliser le code TOTP à la place",
|
||||
"submit_recovery_code": "Soumettre le code de récupération",
|
||||
"please_enter_recovery_code": "Veuillez entrer votre code de récupération.",
|
||||
"recovery_code_verification_failed": "La vérification du code de récupération a échoué",
|
||||
"error_verifying_recovery_code": "Erreur lors de la vérification du code de récupération",
|
||||
"totp_verification_failed": "La vérification TOTP a échoué",
|
||||
"error_verifying_totp_code": "Erreur lors de la vérification du code TOTP",
|
||||
"totp_setup": "Configuration TOTP",
|
||||
"scan_qr_code": "Scannez ce QR code avec votre application d'authentification.",
|
||||
"enter_totp_confirmation": "Entrez le code à 6 chiffres de votre application pour confirmer la configuration :",
|
||||
"confirm": "Confirmer",
|
||||
"please_enter_valid_code": "Veuillez entrer un code valide à 6 chiffres.",
|
||||
"totp_enabled_successfully": "TOTP activé avec succès.",
|
||||
"error_generating_recovery_code": "Erreur lors de la génération du code de récupération",
|
||||
"error_loading_qr_code": "Erreur lors du chargement du QR code.",
|
||||
"error_disabling_totp_setting": "Erreur lors de la désactivation des paramètres TOTP",
|
||||
"user_management": "Gestion des utilisateurs",
|
||||
"add_user": "Ajouter un utilisateur",
|
||||
"remove_user": "Supprimer un utilisateur",
|
||||
"user_permissions": "Permissions des utilisateurs",
|
||||
"oidc_configuration": "Configuration OIDC",
|
||||
"oidc_provider_url": "URL du fournisseur OIDC",
|
||||
"oidc_client_id": "ID du client OIDC",
|
||||
"oidc_client_secret": "Secret du client OIDC",
|
||||
"oidc_redirect_uri": "URI de redirection OIDC",
|
||||
"global_totp_settings": "Paramètres globaux TOTP",
|
||||
"global_otpauth_url": "URL globale OTPAuth",
|
||||
"login_options": "Options de connexion",
|
||||
"disable_login_form": "Désactiver le formulaire de connexion",
|
||||
"disable_basic_http_auth": "Désactiver l'authentification HTTP basique",
|
||||
"disable_oidc_login": "Désactiver la connexion OIDC",
|
||||
"save_settings": "Enregistrer les paramètres",
|
||||
"at_least_one_login_method": "Au moins une méthode de connexion doit rester activée.",
|
||||
"settings_updated_successfully": "Paramètres mis à jour avec succès.",
|
||||
"error_updating_settings": "Erreur lors de la mise à jour des paramètres",
|
||||
"user_permissions_updated_successfully": "Permissions des utilisateurs mises à jour avec succès.",
|
||||
"error_updating_permissions": "Erreur lors de la mise à jour des permissions",
|
||||
"no_users_found": "Aucun utilisateur trouvé.",
|
||||
"user_folder_only": "Uniquement le dossier utilisateur",
|
||||
"read_only": "Lecture seule",
|
||||
"disable_upload": "Désactiver le téléchargement",
|
||||
"error_loading_users": "Erreur lors du chargement des utilisateurs",
|
||||
"save_permissions": "Enregistrer les permissions",
|
||||
"your_recovery_code": "Votre code de récupération",
|
||||
"please_save_recovery_code": "Veuillez sauvegarder ce code en toute sécurité. Il ne sera plus affiché et ne pourra être utilisé qu'une seule fois.",
|
||||
"ok": "OK"
|
||||
},
|
||||
de: {
|
||||
"please_log_in_to_continue": "Bitte melden Sie sich an, um fortzufahren.",
|
||||
"no_files_selected": "Keine Dateien ausgewählt.",
|
||||
"confirm_delete_files": "Sind Sie sicher, dass Sie {count} ausgewählte Datei(en) löschen möchten?",
|
||||
"element_not_found": "Element mit der ID \"{id}\" wurde nicht gefunden.",
|
||||
"search_placeholder": "Dateien, Tags und Uploader suchen...",
|
||||
"search_placeholder_advanced": "Erweiterte Suche: Dateien, Tags, Uploader & Inhalt...",
|
||||
"basic_search_tooltip": "Einfache Suche: Nach Dateiname, Tags und Uploader suchen.",
|
||||
"advanced_search_tooltip": "Erweiterte Suche: Beinhaltet Dateiinhalte zusätzlich zum Dateinamen, Tags und Uploader.",
|
||||
"file_name": "Dateiname",
|
||||
"date_modified": "Änderungsdatum",
|
||||
"upload_date": "Hochladedatum",
|
||||
"file_size": "Dateigröße",
|
||||
"uploader": "Uploader",
|
||||
"enter_totp_code": "Geben Sie den TOTP-Code ein",
|
||||
"use_recovery_code_instead": "Verwenden Sie stattdessen den Wiederherstellungscode",
|
||||
"enter_recovery_code": "Geben Sie den Wiederherstellungscode ein",
|
||||
"editing": "Bearbeitung",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"no_files_found": "Keine Dateien gefunden.",
|
||||
"switch_to_table_view": "Zur Tabellenansicht wechseln",
|
||||
"switch_to_gallery_view": "Zur Galerieansicht wechseln",
|
||||
"share_file": "Datei teilen",
|
||||
"set_expiration": "Ablauf festlegen:",
|
||||
"password_optional": "Passwort (optional):",
|
||||
"generate_share_link": "Freigabelink generieren",
|
||||
"shareable_link": "Freigabelink:",
|
||||
"copy_link": "Link kopieren",
|
||||
"tag_file": "Datei taggen",
|
||||
"tag_name": "Tagname:",
|
||||
"tag_color": "Tagfarbe:",
|
||||
"save_tag": "Tag speichern",
|
||||
"light_mode": "Heller Modus",
|
||||
"dark_mode": "Dunkler Modus",
|
||||
"upload_instruction": "Ziehen Sie Dateien/Ordner hierher oder klicken Sie auf 'Dateien auswählen'",
|
||||
"no_files_selected_default": "Keine Dateien ausgewählt",
|
||||
"choose_files": "Dateien auswählen",
|
||||
"delete_selected": "Ausgewählte löschen",
|
||||
"copy_selected": "Ausgewählte kopieren",
|
||||
"move_selected": "Ausgewählte verschieben",
|
||||
"tag_selected": "Ausgewählte taggen",
|
||||
"download_zip": "Zip herunterladen",
|
||||
"extract_zip": "Zip entpacken",
|
||||
"preview": "Vorschau",
|
||||
"edit": "Bearbeiten",
|
||||
"rename": "Umbenennen",
|
||||
"trash_empty": "Papierkorb ist leer.",
|
||||
"no_trash_selected": "Keine Papierkorbeinträge zur Wiederherstellung ausgewählt.",
|
||||
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"logout": "Abmelden",
|
||||
"change_password": "Passwort ändern",
|
||||
"restore_text": "Wiederherstellen oder",
|
||||
"delete_text": "Papierkorbeinträge löschen",
|
||||
"restore_selected": "Ausgewählte wiederherstellen",
|
||||
"restore_all": "Alle wiederherstellen",
|
||||
"delete_selected_trash": "Ausgewählte löschen",
|
||||
"delete_all": "Alle löschen",
|
||||
"upload_header": "Dateien/Ordner hochladen",
|
||||
|
||||
// Folder Management keys:
|
||||
"folder_navigation": "Ordnernavigation & Verwaltung",
|
||||
"create_folder": "Ordner erstellen",
|
||||
"create_folder_title": "Ordner erstellen",
|
||||
"enter_folder_name": "Geben Sie den Ordnernamen ein",
|
||||
"cancel": "Abbrechen",
|
||||
"create": "Erstellen",
|
||||
"rename_folder": "Ordner umbenennen",
|
||||
"rename_folder_title": "Ordner umbenennen",
|
||||
"rename_folder_placeholder": "Neuen Ordnernamen eingeben",
|
||||
"delete_folder": "Ordner löschen",
|
||||
"delete_folder_title": "Ordner löschen",
|
||||
"delete_folder_message": "Sind Sie sicher, dass Sie diesen Ordner löschen möchten?",
|
||||
"folder_help": "Ordnerhilfe",
|
||||
"folder_help_item_1": "Klicken Sie auf einen Ordner im Baum, um dessen Dateien anzuzeigen.",
|
||||
"folder_help_item_2": "Verwenden Sie [-] zum Einklappen und [+] zum Ausklappen der Ordner.",
|
||||
"folder_help_item_3": "Wählen Sie einen Ordner aus und klicken Sie auf \"Ordner erstellen\", um einen Unterordner hinzuzufügen.",
|
||||
"folder_help_item_4": "Um einen Ordner umzubenennen oder zu löschen, wählen Sie ihn aus und klicken Sie auf den entsprechenden Button.",
|
||||
|
||||
// File List keys:
|
||||
"actions": "Aktionen",
|
||||
"file_list_title": "Dateien in (Root)",
|
||||
"files_in": "Dateien in",
|
||||
"delete_files": "Dateien löschen",
|
||||
"delete_selected_files_title": "Ausgewählte Dateien löschen",
|
||||
"delete_files_message": "Sind Sie sicher, dass Sie die ausgewählten Dateien löschen möchten?",
|
||||
"copy_files": "Dateien kopieren",
|
||||
"copy_files_title": "Ausgewählte Dateien kopieren",
|
||||
"copy_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu kopieren:",
|
||||
"move_files": "Dateien verschieben",
|
||||
"move_files_title": "Ausgewählte Dateien verschieben",
|
||||
"move_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu verschieben:",
|
||||
"move": "Verschieben",
|
||||
"extract_zip_button": "Zip entpacken",
|
||||
"download_zip_title": "Ausgewählte Dateien als Zip herunterladen",
|
||||
"download_zip_prompt": "Geben Sie einen Namen für die Zip-Datei ein:",
|
||||
"zip_placeholder": "files.zip",
|
||||
"share": "Teilen",
|
||||
"total_files": "Gesamtanzahl",
|
||||
"total_size": "Gesamtgröße",
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"page": "Seite",
|
||||
"of": "von",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Anmelden",
|
||||
"remember_me": "Angemeldet bleiben",
|
||||
"login_oidc": "Mit OIDC anmelden",
|
||||
"basic_http_login": "HTTP-Basisauthentifizierung verwenden",
|
||||
|
||||
// Change Password keys:
|
||||
"change_password_title": "Passwort ändern",
|
||||
"old_password": "Altes Passwort",
|
||||
"new_password": "Neues Passwort",
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
|
||||
// Add User keys:
|
||||
"create_new_user_title": "Neuen Benutzer erstellen",
|
||||
"username": "Benutzername:",
|
||||
"password": "Passwort:",
|
||||
"enter_password": "Passwort",
|
||||
"preparing_download": "Bereite Ihren Download vor...",
|
||||
"download_file": "Datei herunterladen",
|
||||
"confirm_or_change_filename": "Bestätigen oder ändern Sie den Dateinamen zum Download:",
|
||||
"filename": "Dateiname",
|
||||
"cancel": "Abbrechen",
|
||||
"download": "Herunterladen",
|
||||
"grant_admin": "Admin-Rechte vergeben",
|
||||
"save_user": "Benutzer speichern",
|
||||
|
||||
// Remove User keys:
|
||||
"remove_user_title": "Benutzer entfernen",
|
||||
"select_user_remove": "Wählen Sie einen Benutzer zum Entfernen aus:",
|
||||
"delete_user": "Benutzer löschen",
|
||||
|
||||
// Rename File keys:
|
||||
"rename_file_title": "Datei umbenennen",
|
||||
"rename_file_placeholder": "Geben Sie den neuen Dateinamen ein",
|
||||
|
||||
// Folder Share
|
||||
"share_folder": "Ordner teilen",
|
||||
"allow_uploads": "Uploads erlauben",
|
||||
"share_link_generated": "Freigabelink generiert",
|
||||
"error_generating_share_link": "Fehler beim Generieren des Freigabelinks",
|
||||
|
||||
// Folder
|
||||
"folder_share": "Ordner teilen",
|
||||
|
||||
// Custom Confirm Modal keys:
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"unsaved_changes_confirm": "Sie haben ungespeicherte Änderungen. Sind Sie sicher, dass Sie schließen möchten, ohne zu speichern?",
|
||||
"delete": "Löschen",
|
||||
"download": "Herunterladen",
|
||||
"upload": "Hochladen",
|
||||
"copy": "Kopieren",
|
||||
"extract": "Entpacken",
|
||||
"user": "Benutzer:",
|
||||
"unknown_error": "Unbekannter Fehler",
|
||||
"link_copied": "Link in die Zwischenablage kopiert",
|
||||
"minutes": "Minuten",
|
||||
"hours": "Stunden",
|
||||
"days": "Tage",
|
||||
"weeks": "Wochen",
|
||||
"months": "Monate",
|
||||
"seconds": "Sekunden",
|
||||
|
||||
// Dark Mode Toggle
|
||||
"dark_mode_toggle": "Dunkler Modus",
|
||||
"light_mode_toggle": "Heller Modus",
|
||||
|
||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||
"admin_panel": "Administrationsbereich",
|
||||
"user_panel": "Benutzerbereich",
|
||||
"trash_restore_delete": "Papierkorb wiederherstellen/löschen",
|
||||
"totp_settings": "TOTP-Einstellungen",
|
||||
"enable_totp": "TOTP aktivieren",
|
||||
"language": "Sprache",
|
||||
"select_language": "Sprache auswählen",
|
||||
"english": "Englisch",
|
||||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
"german": "Deutsch",
|
||||
"use_totp_code_instead": "Stattdessen TOTP-Code verwenden",
|
||||
"submit_recovery_code": "Wiederherstellungscode absenden",
|
||||
"please_enter_recovery_code": "Bitte geben Sie Ihren Wiederherstellungscode ein.",
|
||||
"recovery_code_verification_failed": "Überprüfung des Wiederherstellungscodes fehlgeschlagen",
|
||||
"error_verifying_recovery_code": "Fehler bei der Überprüfung des Wiederherstellungscodes",
|
||||
"totp_verification_failed": "TOTP-Überprüfung fehlgeschlagen",
|
||||
"error_verifying_totp_code": "Fehler bei der Überprüfung des TOTP-Codes",
|
||||
"totp_setup": "TOTP-Einrichtung",
|
||||
"scan_qr_code": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App.",
|
||||
"enter_totp_confirmation": "Geben Sie den 6-stelligen Code aus Ihrer App zur Bestätigung ein:",
|
||||
"confirm": "Bestätigen",
|
||||
"please_enter_valid_code": "Bitte geben Sie einen gültigen 6-stelligen Code ein.",
|
||||
"totp_enabled_successfully": "TOTP wurde erfolgreich aktiviert.",
|
||||
"error_generating_recovery_code": "Fehler beim Generieren des Wiederherstellungscodes",
|
||||
"error_loading_qr_code": "Fehler beim Laden des QR-Codes.",
|
||||
"error_disabling_totp_setting": "Fehler beim Deaktivieren der TOTP-Einstellungen",
|
||||
"user_management": "Benutzerverwaltung",
|
||||
"add_user": "Benutzer hinzufügen",
|
||||
"remove_user": "Benutzer entfernen",
|
||||
"user_permissions": "Benutzerberechtigungen",
|
||||
"oidc_configuration": "OIDC-Konfiguration",
|
||||
"oidc_provider_url": "OIDC-Anbieter-URL",
|
||||
"oidc_client_id": "OIDC-Client-ID",
|
||||
"oidc_client_secret": "OIDC-Client-Geheimnis",
|
||||
"oidc_redirect_uri": "OIDC-Umleitungs-URI",
|
||||
"global_totp_settings": "Globale TOTP-Einstellungen",
|
||||
"global_otpauth_url": "Globale OTPAuth-URL",
|
||||
"login_options": "Anmeldeoptionen",
|
||||
"disable_login_form": "Anmeldeformular deaktivieren",
|
||||
"disable_basic_http_auth": "HTTP-Basisauthentifizierung deaktivieren",
|
||||
"disable_oidc_login": "OIDC-Anmeldung deaktivieren",
|
||||
"save_settings": "Einstellungen speichern",
|
||||
"at_least_one_login_method": "Mindestens eine Anmeldemethode muss aktiviert bleiben.",
|
||||
"settings_updated_successfully": "Einstellungen wurden erfolgreich aktualisiert.",
|
||||
"error_updating_settings": "Fehler beim Aktualisieren der Einstellungen",
|
||||
"user_permissions_updated_successfully": "Benutzerberechtigungen wurden erfolgreich aktualisiert.",
|
||||
"error_updating_permissions": "Fehler beim Aktualisieren der Berechtigungen",
|
||||
"no_users_found": "Keine Benutzer gefunden.",
|
||||
"user_folder_only": "Nur Benutzerordner",
|
||||
"read_only": "Nur Lesen",
|
||||
"disable_upload": "Upload deaktivieren",
|
||||
"error_loading_users": "Fehler beim Laden der Benutzer",
|
||||
"save_permissions": "Berechtigungen speichern",
|
||||
"your_recovery_code": "Ihr Wiederherstellungscode",
|
||||
"please_save_recovery_code": "Bitte speichern Sie diesen Code sicher. Er wird nicht erneut angezeigt und kann nur einmal verwendet werden.",
|
||||
"ok": "OK",
|
||||
"show": "Zeige",
|
||||
"items_per_page": "elemente pro seite"
|
||||
}
|
||||
};
|
||||
|
||||
let currentLocale = 'en';
|
||||
|
||||
export function setLocale(locale) {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
export function t(key, placeholders) {
|
||||
const localeTranslations = translations[currentLocale] || {};
|
||||
let translation = localeTranslations[key] || key;
|
||||
if (placeholders) {
|
||||
Object.keys(placeholders).forEach(ph => {
|
||||
translation = translation.replace(`{${ph}}`, placeholders[ph]);
|
||||
});
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
|
||||
export function applyTranslations() {
|
||||
document.querySelectorAll('[data-i18n-key]').forEach(el => {
|
||||
el.innerText = t(el.getAttribute('data-i18n-key'));
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
el.setAttribute('placeholder', t(el.getAttribute('data-i18n-placeholder')));
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
el.setAttribute('title', t(el.getAttribute('data-i18n-title')));
|
||||
});
|
||||
}
|
||||
181
js/main.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { initUpload } from './upload.js';
|
||||
import { initAuth, checkAuthentication } from './auth.js';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
||||
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
||||
import { displayFilePreview } from './filePreview.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload } from './fileActions.js';
|
||||
import { editFile, saveFile } from './fileEditor.js';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||
|
||||
// Remove the retry logic version and just use loadCsrfToken directly:
|
||||
function loadCsrfToken() {
|
||||
return fetch('token.php', { credentials: 'include' })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Token fetch failed with status: " + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
window.csrfToken = data.csrf_token;
|
||||
window.SHARE_URL = data.share_url;
|
||||
|
||||
let metaCSRF = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!metaCSRF) {
|
||||
metaCSRF = document.createElement('meta');
|
||||
metaCSRF.name = 'csrf-token';
|
||||
document.head.appendChild(metaCSRF);
|
||||
}
|
||||
metaCSRF.setAttribute('content', data.csrf_token);
|
||||
|
||||
let metaShare = document.querySelector('meta[name="share-url"]');
|
||||
if (!metaShare) {
|
||||
metaShare = document.createElement('meta');
|
||||
metaShare.name = 'share-url';
|
||||
document.head.appendChild(metaShare);
|
||||
}
|
||||
metaShare.setAttribute('content', data.share_url);
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Expose functions for inline handlers.
|
||||
window.sendRequest = sendRequest;
|
||||
window.toggleVisibility = toggleVisibility;
|
||||
window.toggleAllCheckboxes = toggleAllCheckboxes;
|
||||
window.editFile = editFile;
|
||||
window.saveFile = saveFile;
|
||||
window.renameFile = renameFile;
|
||||
window.confirmSingleDownload = confirmSingleDownload;
|
||||
window.openDownloadModal = openDownloadModal;
|
||||
|
||||
// Global variable for the current folder.
|
||||
window.currentFolder = "root";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Retrieve the saved language from localStorage; default to "en"
|
||||
const savedLanguage = localStorage.getItem("language") || "en";
|
||||
// Set the locale based on the saved language
|
||||
setLocale(savedLanguage);
|
||||
// Apply the translations to update the UI
|
||||
applyTranslations();
|
||||
// First, load the CSRF token (with retry).
|
||||
loadCsrfToken().then(() => {
|
||||
// Once CSRF token is loaded, initialize authentication.
|
||||
initAuth();
|
||||
|
||||
// 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();
|
||||
|
||||
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.");
|
||||
}
|
||||
});
|
||||
|
||||
// Other DOM initialization that can happen after CSRF is ready.
|
||||
const newPasswordInput = document.getElementById("newPassword");
|
||||
if (newPasswordInput) {
|
||||
newPasswordInput.addEventListener("input", function () {
|
||||
console.log("newPassword input event:", this.value);
|
||||
});
|
||||
} else {
|
||||
console.error("newPassword input not found!");
|
||||
}
|
||||
|
||||
// --- Dark Mode Persistence ---
|
||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||
const storedDarkMode = localStorage.getItem("darkMode");
|
||||
|
||||
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) {
|
||||
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
|
||||
? t("light_mode")
|
||||
: t("dark_mode");
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- End Dark Mode Persistence ---
|
||||
|
||||
const message = sessionStorage.getItem("welcomeMessage");
|
||||
if (message) {
|
||||
showToast(message);
|
||||
sessionStorage.removeItem("welcomeMessage");
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||
});
|
||||
|
||||
// --- 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
|
||||
|
||||
document.addEventListener("dragover", function (e) {
|
||||
if (e.clientY < SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, -SCROLL_SPEED);
|
||||
} else if (e.clientY > window.innerHeight - SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, SCROLL_SPEED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// trashRestoreDelete.js
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileManager.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
function showConfirm(message, onConfirm) {
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
@@ -65,7 +66,7 @@ export function setupTrashRestoreDelete() {
|
||||
const files = Array.from(selected).map(chk => chk.value);
|
||||
console.log("Restore Selected clicked, files:", files);
|
||||
if (files.length === 0) {
|
||||
showToast("No trash items selected for restore.");
|
||||
showToast(t("no_trash_selected"));
|
||||
return;
|
||||
}
|
||||
fetch("restoreFiles.php", {
|
||||
@@ -105,7 +106,7 @@ export function setupTrashRestoreDelete() {
|
||||
const files = Array.from(allChk).map(chk => chk.value);
|
||||
console.log("Restore All clicked, files:", files);
|
||||
if (files.length === 0) {
|
||||
showToast("Trash is empty.");
|
||||
showToast(t("trash_empty"));
|
||||
return;
|
||||
}
|
||||
fetch("restoreFiles.php", {
|
||||
@@ -1,6 +1,9 @@
|
||||
import { loadFileList, displayFilePreview, initFileActions } from './fileManager.js';
|
||||
import { initFileActions } from './fileActions.js';
|
||||
import { displayFilePreview } from './filePreview.js';
|
||||
import { showToast, escapeHTML } from './domUtils.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/* -----------------------------------------------------
|
||||
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
||||
@@ -45,22 +48,19 @@ function getFilesFromDataTransferItems(items) {
|
||||
return Promise.all(promises).then(results => results.flat());
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------
|
||||
UI Helpers (Mostly unchanged from your original code)
|
||||
----------------------------------------------------- */
|
||||
function setDropAreaDefault() {
|
||||
const dropArea = document.getElementById("uploadDropArea");
|
||||
if (dropArea) {
|
||||
dropArea.innerHTML = `
|
||||
<div id="uploadInstruction" class="upload-instruction">
|
||||
Drop files/folders here or click 'Choose files'
|
||||
${t("upload_instruction")}
|
||||
</div>
|
||||
<div id="uploadFileRow" class="upload-file-row">
|
||||
<button id="customChooseBtn" type="button">Choose files</button>
|
||||
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
||||
</div>
|
||||
<div id="fileInfoWrapper" class="file-info-wrapper">
|
||||
<div id="fileInfoContainer" class="file-info-container">
|
||||
<span id="fileInfoDefault">No files selected</span>
|
||||
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- File input for file picker (files only) -->
|
||||
@@ -497,11 +497,9 @@ function initResumableUpload() {
|
||||
resumableInstance.on("fileSuccess", function(file, message) {
|
||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
||||
if (li && li.progressBar) {
|
||||
// Clear any merging indicators.
|
||||
li.progressBar.style.width = "100%";
|
||||
li.progressBar.innerText = "Done";
|
||||
|
||||
// Optionally hide the pause/resume and remove buttons.
|
||||
// Hide pause/resume and remove buttons for successful files.
|
||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||
if (pauseResumeBtn) {
|
||||
pauseResumeBtn.style.display = "none";
|
||||
@@ -510,9 +508,17 @@ function initResumableUpload() {
|
||||
if (removeBtn) {
|
||||
removeBtn.style.display = "none";
|
||||
}
|
||||
// Schedule removal of the file entry after 5 seconds.
|
||||
setTimeout(() => {
|
||||
li.remove();
|
||||
window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier);
|
||||
updateFileInfoCount();
|
||||
}, 5000);
|
||||
}
|
||||
loadFileList(window.currentFolder);
|
||||
});
|
||||
|
||||
|
||||
|
||||
resumableInstance.on("fileError", function (file, message) {
|
||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
||||
@@ -521,7 +527,6 @@ function initResumableUpload() {
|
||||
}
|
||||
// Mark file as errored so that the pause/resume button acts as a restart button.
|
||||
file.isError = true;
|
||||
// Change the pause/resume button to show a restart icon.
|
||||
const pauseResumeBtn = li ? li.querySelector(".pause-resume-btn") : null;
|
||||
if (pauseResumeBtn) {
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">replay</span>';
|
||||
@@ -531,17 +536,17 @@ function initResumableUpload() {
|
||||
});
|
||||
|
||||
resumableInstance.on("complete", function () {
|
||||
// Check if any file in the current selection is marked with an error.
|
||||
// If any file is marked with an error, leave the list intact.
|
||||
const hasError = window.selectedFiles.some(f => f.isError);
|
||||
if (!hasError) {
|
||||
// All files succeeded; clear the file list after 5 seconds.
|
||||
// All files succeeded—clear the file input and progress container after 5 seconds.
|
||||
setTimeout(() => {
|
||||
const fileInput = document.getElementById("file");
|
||||
if (fileInput) fileInput.value = "";
|
||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||
progressContainer.innerHTML = "";
|
||||
window.selectedFiles = [];
|
||||
adjustFolderHelpExpansionClosed();
|
||||
window.addEventListener("resize", adjustFolderHelpExpansionClosed);
|
||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||
if (fileInfoContainer) {
|
||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||
@@ -668,40 +673,39 @@ function submitFiles(allFiles) {
|
||||
.then(serverFiles => {
|
||||
initFileActions();
|
||||
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
|
||||
let allSucceeded = true;
|
||||
let overallSuccess = true;
|
||||
allFiles.forEach(file => {
|
||||
// For files without a relative path
|
||||
if ((file.webkitRelativePath || file.customRelativePath || "").trim() === "") {
|
||||
const clientFileName = file.name.trim().toLowerCase();
|
||||
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
|
||||
const li = progressElements[file.uploadIndex];
|
||||
if (li) {
|
||||
li.progressBar.innerText = "Error";
|
||||
}
|
||||
allSucceeded = false;
|
||||
const clientFileName = file.name.trim().toLowerCase();
|
||||
const li = progressElements[file.uploadIndex];
|
||||
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
|
||||
if (li) {
|
||||
li.progressBar.innerText = "Error";
|
||||
}
|
||||
overallSuccess = false;
|
||||
} else if (li) {
|
||||
// Schedule removal of successful file entry after 5 seconds.
|
||||
setTimeout(() => {
|
||||
li.remove();
|
||||
delete progressElements[file.uploadIndex];
|
||||
updateFileInfoCount();
|
||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) {
|
||||
const fileInput = document.getElementById("file");
|
||||
if (fileInput) fileInput.value = "";
|
||||
progressContainer.innerHTML = "";
|
||||
adjustFolderHelpExpansionClosed();
|
||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||
if (fileInfoContainer) {
|
||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||
}
|
||||
const dropArea = document.getElementById("uploadDropArea");
|
||||
if (dropArea) setDropAreaDefault();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
if (allSucceeded) {
|
||||
// All files succeeded—clear the list after 5 seconds.
|
||||
setTimeout(() => {
|
||||
if (fileInput) fileInput.value = "";
|
||||
const removeBtns = progressContainer.querySelectorAll("button.remove-file-btn");
|
||||
removeBtns.forEach(btn => btn.style.display = "none");
|
||||
progressContainer.innerHTML = "";
|
||||
window.selectedFiles = [];
|
||||
adjustFolderHelpExpansionClosed();
|
||||
window.addEventListener("resize", adjustFolderHelpExpansionClosed);
|
||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||
if (fileInfoContainer) {
|
||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||
}
|
||||
const dropArea = document.getElementById("uploadDropArea");
|
||||
if (dropArea) setDropAreaDefault();
|
||||
}, 5000);
|
||||
} else {
|
||||
// Some files failed—keep the list visible and show a toast.
|
||||
if (!overallSuccess) {
|
||||
showToast("Some files failed to upload. Please check the list.");
|
||||
}
|
||||
})
|
||||
100
login_basic.php
@@ -3,6 +3,19 @@ require_once 'config.php';
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE; // Make sure the users file path is defined
|
||||
|
||||
// Helper: retrieve a user's TOTP secret from users.txt
|
||||
function getUserTOTPSecret($username) {
|
||||
global $encryptionKey, $usersFile;
|
||||
if (!file_exists($usersFile)) return null;
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||
return decryptData($parts[3], $encryptionKey);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reuse the same authentication function
|
||||
function authenticate($username, $password)
|
||||
{
|
||||
@@ -43,15 +56,9 @@ function loadFolderPermission($username) {
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
if (file_exists($permissionsFile)) {
|
||||
$content = file_get_contents($permissionsFile);
|
||||
// Try to decrypt the content.
|
||||
$decryptedContent = decryptData($content, $encryptionKey);
|
||||
if ($decryptedContent !== false) {
|
||||
$permissions = json_decode($decryptedContent, true);
|
||||
} else {
|
||||
$permissions = json_decode($content, true);
|
||||
}
|
||||
$decrypted = decryptData($content, $encryptionKey);
|
||||
$permissions = $decrypted !== false ? json_decode($decrypted, true) : json_decode($content, true);
|
||||
if (is_array($permissions)) {
|
||||
// Use case-insensitive comparison.
|
||||
foreach ($permissions as $storedUsername => $data) {
|
||||
if (strcasecmp($storedUsername, $username) === 0 && isset($data['folderOnly'])) {
|
||||
return (bool)$data['folderOnly'];
|
||||
@@ -59,7 +66,7 @@ function loadFolderPermission($username) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // Default if not set.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the user has sent HTTP Basic auth credentials.
|
||||
@@ -68,39 +75,46 @@ if (!isset($_SERVER['PHP_AUTH_USER'])) {
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Authorization Required';
|
||||
exit;
|
||||
} else {
|
||||
$username = trim($_SERVER['PHP_AUTH_USER']);
|
||||
$password = trim($_SERVER['PHP_AUTH_PW']);
|
||||
|
||||
// Validate username format (optional)
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid username format';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Attempt authentication
|
||||
$roleFromAuth = authenticate($username, $password);
|
||||
if ($roleFromAuth !== false) {
|
||||
// Use getUserRole() to determine the user's role from the file
|
||||
$actualRole = getUserRole($username);
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = ($actualRole === "1");
|
||||
// Set the folderOnly flag based on userPermissions.json.
|
||||
$_SESSION["folderOnly"] = loadFolderPermission($username);
|
||||
|
||||
// Redirect to the main page (or output JSON for testing)
|
||||
header("Location: index.html");
|
||||
exit;
|
||||
} else {
|
||||
// Invalid credentials; prompt again
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid credentials';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$username = trim($_SERVER['PHP_AUTH_USER']);
|
||||
$password = trim($_SERVER['PHP_AUTH_PW']);
|
||||
|
||||
// Validate username format (optional)
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid username format';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Attempt authentication
|
||||
$roleFromAuth = authenticate($username, $password);
|
||||
if ($roleFromAuth !== false) {
|
||||
// --- NEW: check for TOTP secret ---
|
||||
$secret = getUserTOTPSecret($username);
|
||||
if ($secret) {
|
||||
// hold user & secret in session and ask client for TOTP
|
||||
$_SESSION['pending_login_user'] = $username;
|
||||
$_SESSION['pending_login_secret'] = $secret;
|
||||
header("Location: index.html?totp_required=1");
|
||||
exit;
|
||||
}
|
||||
|
||||
// no TOTP, proceed as before
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = (getUserRole($username) === "1");
|
||||
$_SESSION["folderOnly"] = loadFolderPermission($username);
|
||||
|
||||
header("Location: index.html");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Invalid credentials; prompt again
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid credentials';
|
||||
exit;
|
||||
?>
|
||||
21
logout.php
@@ -5,12 +5,12 @@ require_once 'config.php';
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
// If there's a mismatch, log it but continue with logout.
|
||||
// Log CSRF mismatch but proceed with logout.
|
||||
if (isset($_SESSION['csrf_token']) && $receivedToken !== $_SESSION['csrf_token']) {
|
||||
error_log("CSRF token mismatch on logout. Proceeding with logout.");
|
||||
}
|
||||
|
||||
// If the remember me token is set, remove it from the persistent tokens file.
|
||||
// Remove the remember_me token.
|
||||
if (isset($_COOKIE['remember_me_token'])) {
|
||||
$token = $_COOKIE['remember_me_token'];
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
@@ -25,13 +25,26 @@ if (isset($_COOKIE['remember_me_token'])) {
|
||||
}
|
||||
}
|
||||
// Clear the cookie.
|
||||
// Ensure $secure is defined; for example:
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
// Clear session data and destroy the session.
|
||||
// Clear session data and remove session cookie.
|
||||
$_SESSION = [];
|
||||
|
||||
// Clear the session cookie.
|
||||
if (ini_get("session.use_cookies")) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
}
|
||||
|
||||
// Destroy the session.
|
||||
session_destroy();
|
||||
|
||||
header("Location: index.html");
|
||||
header("Location: index.html?logout=1");
|
||||
exit;
|
||||
?>
|
||||
168
main.js
@@ -1,168 +0,0 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import {
|
||||
toggleVisibility,
|
||||
toggleAllCheckboxes,
|
||||
updateFileActionButtons,
|
||||
showToast
|
||||
} from './domUtils.js';
|
||||
import {
|
||||
loadFileList,
|
||||
initFileActions,
|
||||
editFile,
|
||||
saveFile,
|
||||
displayFilePreview,
|
||||
renameFile
|
||||
} from './fileManager.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { initUpload } from './upload.js';
|
||||
import { initAuth, checkAuthentication } from './auth.js';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||
import { initDragAndDrop, loadSidebarOrder } from './dragAndDrop.js'
|
||||
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
||||
|
||||
function loadCsrfToken() {
|
||||
fetch('token.php', { credentials: 'include' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Set global variables.
|
||||
window.csrfToken = data.csrf_token;
|
||||
window.SHARE_URL = data.share_url;
|
||||
|
||||
// Update (or create) the CSRF meta tag.
|
||||
let metaCSRF = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!metaCSRF) {
|
||||
metaCSRF = document.createElement('meta');
|
||||
metaCSRF.name = 'csrf-token';
|
||||
document.head.appendChild(metaCSRF);
|
||||
}
|
||||
metaCSRF.setAttribute('content', data.csrf_token);
|
||||
|
||||
// Update (or create) the share URL meta tag.
|
||||
let metaShare = document.querySelector('meta[name="share-url"]');
|
||||
if (!metaShare) {
|
||||
metaShare = document.createElement('meta');
|
||||
metaShare.name = 'share-url';
|
||||
document.head.appendChild(metaShare);
|
||||
}
|
||||
metaShare.setAttribute('content', data.share_url);
|
||||
})
|
||||
.catch(error => console.error("Error loading CSRF token and share URL:", error));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadCsrfToken);
|
||||
|
||||
// Expose functions for inline handlers.
|
||||
window.sendRequest = sendRequest;
|
||||
window.toggleVisibility = toggleVisibility;
|
||||
window.toggleAllCheckboxes = toggleAllCheckboxes;
|
||||
window.editFile = editFile;
|
||||
window.saveFile = saveFile;
|
||||
window.renameFile = renameFile;
|
||||
|
||||
// Global variable for the current folder.
|
||||
window.currentFolder = "root";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Call initAuth synchronously.
|
||||
initAuth();
|
||||
|
||||
const newPasswordInput = document.getElementById("newPassword");
|
||||
if (newPasswordInput) {
|
||||
newPasswordInput.addEventListener("input", function() {
|
||||
console.log("newPassword input event:", this.value);
|
||||
});
|
||||
} else {
|
||||
console.error("newPassword input not found!");
|
||||
}
|
||||
// --- Dark Mode Persistence ---
|
||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||
const storedDarkMode = localStorage.getItem("darkMode");
|
||||
|
||||
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) {
|
||||
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
|
||||
? "Light Mode"
|
||||
: "Dark Mode";
|
||||
|
||||
darkModeToggle.addEventListener("click", function () {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
document.body.classList.remove("dark-mode");
|
||||
localStorage.setItem("darkMode", "false");
|
||||
darkModeToggle.textContent = "Dark Mode";
|
||||
} else {
|
||||
document.body.classList.add("dark-mode");
|
||||
localStorage.setItem("darkMode", "true");
|
||||
darkModeToggle.textContent = "Light Mode";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = "Light Mode";
|
||||
} else {
|
||||
document.body.classList.remove("dark-mode");
|
||||
if (darkModeToggle) darkModeToggle.textContent = "Dark Mode";
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- End Dark Mode Persistence ---
|
||||
|
||||
const message = sessionStorage.getItem("welcomeMessage");
|
||||
if (message) {
|
||||
showToast(message);
|
||||
sessionStorage.removeItem("welcomeMessage");
|
||||
}
|
||||
|
||||
checkAuthentication().then(authenticated => {
|
||||
if (authenticated) {
|
||||
window.currentFolder = "root";
|
||||
initTagSearch();
|
||||
loadFileList(window.currentFolder);
|
||||
initDragAndDrop();
|
||||
loadSidebarOrder();
|
||||
initFileActions();
|
||||
initUpload();
|
||||
loadFolderTree();
|
||||
setupTrashRestoreDelete();
|
||||
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.");
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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
|
||||
|
||||
document.addEventListener("dragover", function (e) {
|
||||
if (e.clientY < SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, -SCROLL_SPEED);
|
||||
} else if (e.clientY > window.innerHeight - SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, SCROLL_SPEED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
@@ -20,9 +17,8 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -46,7 +42,7 @@ $sourceFolder = trim($data['source']) ?: 'root';
|
||||
$destinationFolder = trim($data['destination']) ?: 'root';
|
||||
|
||||
// Allow only letters, numbers, underscores, dashes, spaces, and forward slashes in folder names.
|
||||
$folderPattern = '/^[A-Za-z0-9_\- \/]+$/';
|
||||
$folderPattern = REGEX_FOLDER_NAME;
|
||||
if ($sourceFolder !== 'root' && !preg_match($folderPattern, $sourceFolder)) {
|
||||
echo json_encode(["error" => "Invalid source folder name."]);
|
||||
exit;
|
||||
@@ -112,7 +108,7 @@ $srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMet
|
||||
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
|
||||
|
||||
$errors = [];
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
foreach ($data['files'] as $fileName) {
|
||||
// Save the original name for metadata lookup.
|
||||
|
||||
@@ -17,9 +17,9 @@ if (!isset($_POST['folder'])) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = $_POST['folder'];
|
||||
// Validate the folder name (only alphanumerics, dashes allowed)
|
||||
if (!preg_match('/^resumable_[A-Za-z0-9\-]+$/', $folder)) {
|
||||
$folder = urldecode($_POST['folder']);
|
||||
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u"; // full regex pattern
|
||||
if (!preg_match($regex, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
http_response_code(400);
|
||||
exit;
|
||||
|
||||
@@ -30,7 +30,7 @@ if (!$usernameToRemove) {
|
||||
}
|
||||
|
||||
// Optional: Validate the username format (allow letters, numbers, underscores, dashes, and spaces)
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $usernameToRemove)) {
|
||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||
echo json_encode(["error" => "Invalid username format"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -40,7 +40,7 @@ if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($dat
|
||||
|
||||
$folder = trim($data['folder']) ?: 'root';
|
||||
// For subfolders, allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
exit;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ $oldName = basename(trim($data['oldName']));
|
||||
$newName = basename(trim($data['newName']));
|
||||
|
||||
// Validate file names: allow letters, numbers, underscores, dashes, dots, parentheses, and spaces.
|
||||
if (!preg_match('/^[A-Za-z0-9_\-\. \(\)]+$/', $oldName) || !preg_match('/^[A-Za-z0-9_\-\. \(\)]+$/', $newName)) {
|
||||
if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) {
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
@@ -27,9 +24,8 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -49,7 +45,7 @@ $oldFolder = trim($input['oldFolder']);
|
||||
$newFolder = trim($input['newFolder']);
|
||||
|
||||
// Validate folder names
|
||||
if (!preg_match('/^[A-Za-z0-9_\- \/]+$/', $oldFolder) || !preg_match('/^[A-Za-z0-9_\- \/]+$/', $newFolder)) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid folder name(s).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 410 KiB |
BIN
resources/dark-header.png
Normal file
|
After Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 4.0 MiB |
BIN
resources/dark-sidebar.png
Normal file
|
After Width: | Height: | Size: 560 KiB |
BIN
resources/dark-totp-setup.png
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
resources/light-admin-panel.png
Normal file
|
After Width: | Height: | Size: 438 KiB |
BIN
resources/light-drag-file.png
Normal file
|
After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 4.0 MiB |
BIN
resources/light-topbar.png
Normal file
|
After Width: | Height: | Size: 403 KiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 457 KiB |
@@ -1 +0,0 @@
|
||||
This resource folder is just to hold images of design
|
||||
@@ -53,7 +53,7 @@ if (!isset($data['files']) || !is_array($data['files'])) {
|
||||
}
|
||||
|
||||
// Define a safe file name pattern.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
$restoredItems = [];
|
||||
$errors = [];
|
||||
|
||||
@@ -18,9 +18,8 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
@@ -49,7 +48,7 @@ $folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
|
||||
|
||||
// If a subfolder is provided, validate it.
|
||||
// Allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
if ($folder !== "root" && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,21 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
}
|
||||
|
||||
// CSRF Protection: validate token from header.
|
||||
$headers = getallheaders();
|
||||
if (!isset($headers['X-CSRF-Token']) || $headers['X-CSRF-Token'] !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token."]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
respond('error', 403, 'Invalid CSRF token');
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to file tags"]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve and sanitize input.
|
||||
@@ -77,7 +87,7 @@ if ($file === "global") {
|
||||
}
|
||||
|
||||
// Validate folder name.
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
82
share.php
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
// share.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
// Get token and password (if provided)
|
||||
$token = isset($_GET['token']) ? $_GET['token'] : '';
|
||||
$providedPass = isset($_GET['pass']) ? $_GET['pass'] : '';
|
||||
// Retrieve and sanitize input
|
||||
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
|
||||
$providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
|
||||
|
||||
if (empty($token)) {
|
||||
http_response_code(400);
|
||||
@@ -12,7 +13,7 @@ if (empty($token)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load share links.
|
||||
// Load share links from file
|
||||
$shareFile = META_DIR . "share_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
http_response_code(404);
|
||||
@@ -36,18 +37,54 @@ if (time() > $record['expires']) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// If a password is required and none is provided, show a simple form.
|
||||
// If a password is required and none is provided, show a password form.
|
||||
if (!empty($record['password']) && empty($providedPass)) {
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Enter Password</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
color: #333;
|
||||
}
|
||||
form {
|
||||
max-width: 400px;
|
||||
margin: 40px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background: #007BFF;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<body>
|
||||
<h2>This file is protected by a password.</h2>
|
||||
<form method="get" action="share.php">
|
||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token); ?>">
|
||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" name="pass" id="pass" required>
|
||||
<button type="submit">Submit</button>
|
||||
@@ -58,7 +95,7 @@ if (!empty($record['password']) && empty($providedPass)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// If a password was set, validate it.
|
||||
// Validate provided password if set.
|
||||
if (!empty($record['password'])) {
|
||||
if (!password_verify($providedPass, $record['password'])) {
|
||||
http_response_code(403);
|
||||
@@ -67,7 +104,7 @@ if (!empty($record['password'])) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build file path.
|
||||
// Build file path securely.
|
||||
$folder = trim($record['folder'], "/\\ ");
|
||||
$file = $record['file'];
|
||||
$filePath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
@@ -76,24 +113,37 @@ if (!empty($folder) && strtolower($folder) !== 'root') {
|
||||
}
|
||||
$filePath .= $file;
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
// Resolve the real path and ensure it's within the allowed directory.
|
||||
$realFilePath = realpath($filePath);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "File not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!file_exists($realFilePath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "File not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve the file.
|
||||
$mimeType = mime_content_type($filePath);
|
||||
$mimeType = mime_content_type($realFilePath);
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// Determine extension and set disposition accordingly.
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
// Set Content-Disposition based on file type.
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) {
|
||||
header('Content-Disposition: inline; filename="' . basename($filePath) . '"');
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
|
||||
readfile($filePath);
|
||||
// Optionally disable caching for sensitive files.
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
?>
|
||||
356
shareFolder.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
// shareFolder.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
// Retrieve token and optional password from GET.
|
||||
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
|
||||
$providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
|
||||
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);
|
||||
if ($page === false || $page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
if (empty($token)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Missing token."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load share folder records.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Share link not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Share link not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$record = $shareLinks[$token];
|
||||
|
||||
// Check expiration.
|
||||
if (time() > $record['expires']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "This link has expired."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// If password protection is enabled and no password is provided, show password form.
|
||||
if (!empty($record['password']) && empty($providedPass)) {
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Enter Password</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f7f7f7;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
margin: 80px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
label, input, button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
input[type="password"] {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
background-color: #007BFF;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Folder Protected</h2>
|
||||
<p>This folder is protected by a password.</p>
|
||||
<form method="get" action="shareFolder.php">
|
||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" name="pass" id="pass" required>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate the provided password if required.
|
||||
if (!empty($record['password'])) {
|
||||
if (!password_verify($providedPass, $record['password'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid password."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the folder path.
|
||||
$folder = trim($record['folder'], "/\\ ");
|
||||
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
$realFolderPath = realpath($folderPath);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
|
||||
// Validate that the folder exists and is within UPLOAD_DIR.
|
||||
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Folder not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Scan and sort files.
|
||||
$allFiles = array_values(array_filter(scandir($realFolderPath), function($item) use ($realFolderPath) {
|
||||
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item);
|
||||
}));
|
||||
sort($allFiles);
|
||||
|
||||
// Pagination variables.
|
||||
$itemsPerPage = 10;
|
||||
$totalFiles = count($allFiles);
|
||||
$totalPages = max(1, ceil($totalFiles / $itemsPerPage));
|
||||
$currentPage = min($page, $totalPages);
|
||||
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
||||
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
|
||||
|
||||
/**
|
||||
* Convert file size in bytes into a human-readable string.
|
||||
*
|
||||
* @param int $bytes The file size in bytes.
|
||||
* @return string The formatted size string.
|
||||
*/
|
||||
function formatBytes($bytes) {
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . " B";
|
||||
} elseif ($bytes < 1024 * 1024) {
|
||||
return round($bytes / 1024, 2) . " KB";
|
||||
} elseif ($bytes < 1024 * 1024 * 1024) {
|
||||
return round($bytes / (1024 * 1024), 2) . " MB";
|
||||
} else {
|
||||
return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Shared Folder: <?php echo htmlspecialchars($folder, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
background: #f2f2f2;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #007BFF;
|
||||
color: #fff;
|
||||
font-weight: normal;
|
||||
}
|
||||
tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
a {
|
||||
color: #007BFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Simple download icon style */
|
||||
.download-icon {
|
||||
margin-left: 8px;
|
||||
font-weight: bold;
|
||||
color: #007BFF;
|
||||
}
|
||||
/* Pagination styles */
|
||||
.pagination {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pagination a, .pagination span {
|
||||
margin: 0 5px;
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
background: #007BFF;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pagination span.current {
|
||||
background: #0056b3;
|
||||
}
|
||||
.upload-container {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.upload-container h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.upload-container form {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.upload-container button {
|
||||
background-color: #28a745;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-container button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
font-size: 0.9rem;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Shared Folder: <?php echo htmlspecialchars($folder, ENT_QUOTES, 'UTF-8'); ?></h1>
|
||||
</div>
|
||||
<div class="container">
|
||||
<?php if (empty($filesOnPage)): ?>
|
||||
<p style="text-align:center;">This folder is empty.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($filesOnPage as $file):
|
||||
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||||
$fileSize = formatBytes(filesize($filePath));
|
||||
// Build download link using share token and file name.
|
||||
$downloadLink = "downloadSharedFile.php?token=" . urlencode($token) . "&file=" . urlencode($file);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="<?php echo htmlspecialchars($downloadLink, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php echo htmlspecialchars($file, ENT_QUOTES, 'UTF-8'); ?>
|
||||
<span class="download-icon">⇩</span>
|
||||
</a>
|
||||
</td>
|
||||
<td><?php echo $fileSize; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination">
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $currentPage - 1; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>">Prev</a>
|
||||
<?php else: ?>
|
||||
<span>Prev</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Display up to 5 page links centered around the current page.
|
||||
$startPage = max(1, $currentPage - 2);
|
||||
$endPage = min($totalPages, $currentPage + 2);
|
||||
for ($i = $startPage; $i <= $endPage; $i++): ?>
|
||||
<?php if ($i == $currentPage): ?>
|
||||
<span class="current"><?php echo $i; ?></span>
|
||||
<?php else: ?>
|
||||
<a href="shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $i; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>"><?php echo $i; ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $currentPage + 1; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>">Next</a>
|
||||
<?php else: ?>
|
||||
<span>Next</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($record['allowUpload']) : ?>
|
||||
<div class="upload-container">
|
||||
<h3>Upload File (50mb max size)</h3>
|
||||
<form action="uploadToSharedFolder.php" method="post" enctype="multipart/form-data">
|
||||
<!-- Passing token so the upload endpoint can verify the share link. -->
|
||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<input type="file" name="fileToUpload" required>
|
||||
<br><br>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© <?php echo date("Y"); ?> FileRise. All rights reserved.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,11 +11,11 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
}
|
||||
|
||||
// Verify CSRF token from request headers.
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
respond('error', 403, 'Invalid CSRF token');
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
115
totp_recover.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
// totp_recover.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// ——— 1) Only POST ———
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
error_log("Recovery attempt with invalid method: {$_SERVER['REQUEST_METHOD']}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Method not allowed']));
|
||||
}
|
||||
|
||||
// ——— 2) CSRF check ———
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
respond('error', 403, 'Invalid CSRF token');
|
||||
}
|
||||
|
||||
// ——— 3) Identify user to recover ———
|
||||
$userId = $_SESSION['username']
|
||||
?? $_SESSION['pending_login_user']
|
||||
?? null;
|
||||
|
||||
if (!$userId) {
|
||||
http_response_code(401);
|
||||
error_log("Unauthorized recovery attempt from IP {$_SERVER['REMOTE_ADDR']}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Unauthorized']));
|
||||
}
|
||||
|
||||
// ——— Validate userId format ———
|
||||
if (!preg_match(REGEX_USER, $userId)) {
|
||||
http_response_code(400);
|
||||
error_log("Invalid userId format: {$userId}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Invalid user identifier']));
|
||||
}
|
||||
|
||||
// ——— Rate‑limit recovery attempts ———
|
||||
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
||||
$attempts = is_file($attemptsFile)
|
||||
? json_decode(file_get_contents($attemptsFile), true)
|
||||
: [];
|
||||
$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId;
|
||||
$now = time();
|
||||
// Prune >15 min old
|
||||
if (isset($attempts[$key])) {
|
||||
$attempts[$key] = array_filter(
|
||||
$attempts[$key],
|
||||
fn($ts) => $ts > $now - 900
|
||||
);
|
||||
}
|
||||
if (count($attempts[$key] ?? []) >= 5) {
|
||||
http_response_code(429);
|
||||
exit(json_encode(['status'=>'error','message'=>'Too many attempts. Try again later.']));
|
||||
}
|
||||
|
||||
// ——— 4) Load user metadata file ———
|
||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||
if (!file_exists($userFile)) {
|
||||
http_response_code(404);
|
||||
error_log("User file not found for recovery: {$userFile}");
|
||||
exit(json_encode(['status'=>'error','message'=>'User not found']));
|
||||
}
|
||||
|
||||
// ——— 5) Read & lock file ———
|
||||
$fp = fopen($userFile, 'c+');
|
||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||
http_response_code(500);
|
||||
error_log("Failed to lock user file: {$userFile}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Server error']));
|
||||
}
|
||||
$data = json_decode(stream_get_contents($fp), true) ?: [];
|
||||
|
||||
// ——— 6) Verify recovery code ———
|
||||
$input = json_decode(file_get_contents('php://input'), true)['recovery_code'] ?? '';
|
||||
if (!$input) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
http_response_code(400);
|
||||
exit(json_encode(['status'=>'error','message'=>'Recovery code required']));
|
||||
}
|
||||
|
||||
$hash = $data['totp_recovery_code'] ?? null;
|
||||
if (!$hash || !password_verify($input, $hash)) {
|
||||
// record failed attempt
|
||||
$attempts[$key][] = $now;
|
||||
file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX);
|
||||
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
error_log("Invalid recovery code for user {$userId} from IP {$_SERVER['REMOTE_ADDR']}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Invalid recovery code']));
|
||||
}
|
||||
|
||||
// ——— 7) Invalidate code & save ———
|
||||
$data['totp_recovery_code'] = null;
|
||||
rewind($fp);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode($data)); // no pretty-print in prod
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
// ——— 8) Finalize login ———
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $userId;
|
||||
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
|
||||
|
||||
// ——— 9) Success ———
|
||||
echo json_encode(['status'=>'ok']);
|
||||
exit;
|
||||
85
totp_saveCode.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// totp_saveCode.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// 1) Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Method not allowed']));
|
||||
}
|
||||
|
||||
// 2) CSRF check
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
respond('error', 403, 'Invalid CSRF token');
|
||||
}
|
||||
|
||||
// 3) Must be logged in
|
||||
if (empty($_SESSION['username'])) {
|
||||
http_response_code(401);
|
||||
error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Unauthorized']));
|
||||
}
|
||||
|
||||
// 4) Validate username format
|
||||
$userId = $_SESSION['username'];
|
||||
if (!preg_match(REGEX_USER, $userId)) {
|
||||
http_response_code(400);
|
||||
error_log("totp_saveCode: invalid username format: {$userId}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Invalid user identifier']));
|
||||
}
|
||||
|
||||
// 5) Ensure user file exists (create if missing)
|
||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||
if (!file_exists($userFile)) {
|
||||
$defaultData = [];
|
||||
if (file_put_contents($userFile, json_encode($defaultData)) === false) {
|
||||
http_response_code(500);
|
||||
error_log("totp_saveCode: failed to create user file: {$userFile}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Server error']));
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Generate secure recovery code
|
||||
function generateRecoveryCode($length = 12) {
|
||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
$max = strlen($chars) - 1;
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, $max)];
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
$recoveryCode = generateRecoveryCode();
|
||||
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
||||
|
||||
// 7) Read, lock, update user file
|
||||
$fp = fopen($userFile, 'c+');
|
||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||
http_response_code(500);
|
||||
error_log("totp_saveCode: failed to lock user file: {$userFile}");
|
||||
exit(json_encode(['status'=>'error','message'=>'Server error']));
|
||||
}
|
||||
|
||||
$data = json_decode(stream_get_contents($fp), true) ?: [];
|
||||
$data['totp_recovery_code'] = $recoveryHash;
|
||||
|
||||
rewind($fp);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode($data)); // no pretty-print in prod
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
// 8) Return one-time recovery code
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'recoveryCode' => $recoveryCode
|
||||
]);
|
||||
exit;
|
||||
@@ -6,19 +6,35 @@ require_once 'config.php';
|
||||
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
|
||||
use RobThree\Auth\Algorithm;
|
||||
use RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider;
|
||||
|
||||
// For debugging purposes, you might enable error reporting temporarily:
|
||||
// ini_set('display_errors', 1);
|
||||
// error_reporting(E_ALL);
|
||||
// Define the respond() helper if not already defined.
|
||||
if (!function_exists('respond')) {
|
||||
function respond($status, $code, $message, $data = []) {
|
||||
http_response_code($code);
|
||||
echo json_encode([
|
||||
'status' => $status,
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
// Allow access if the user is authenticated or pending TOTP.
|
||||
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify CSRF token provided as a GET parameter.
|
||||
if (!isset($_GET['csrf']) || $_GET['csrf'] !== $_SESSION['csrf_token']) {
|
||||
// Retrieve CSRF token from GET parameter or request headers.
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
@@ -108,7 +124,13 @@ function getGlobalOtpauthUrl() {
|
||||
return "";
|
||||
}
|
||||
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new GoogleChartsQrCodeProvider(), // QR code provider
|
||||
'FileRise', // issuer
|
||||
6, // number of digits
|
||||
30, // period in seconds
|
||||
Algorithm::Sha1 // enum case from your Algorithm enum
|
||||
);
|
||||
|
||||
// Retrieve the current TOTP secret for the user.
|
||||
$totpSecret = getUserTOTPSecret($username);
|
||||
@@ -120,8 +142,6 @@ if (!$totpSecret) {
|
||||
}
|
||||
|
||||
// Determine the otpauth URL to use.
|
||||
// If a global OTPAuth URL template is defined, replace placeholders {label} and {secret}.
|
||||
// Otherwise, use the default method.
|
||||
$globalOtpauthUrl = getGlobalOtpauthUrl();
|
||||
if (!empty($globalOtpauthUrl)) {
|
||||
$label = "FileRise:" . $username;
|
||||
@@ -140,7 +160,6 @@ if (!empty($globalOtpauthUrl)) {
|
||||
$result = Builder::create()
|
||||
->writer(new PngWriter())
|
||||
->data($otpauthUrl)
|
||||
->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
|
||||
->build();
|
||||
|
||||
header('Content-Type: ' . $result->getMimeType());
|
||||
|
||||
211
totp_verify.php
@@ -1,84 +1,157 @@
|
||||
<?php
|
||||
// verifyTOTPSetup.php
|
||||
// totp_verify.php
|
||||
|
||||
require_once 'vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Not authenticated"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify CSRF token from request headers.
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure Content-Type is JSON.
|
||||
// JSON + CSP
|
||||
header('Content-Type: application/json');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||
|
||||
// Read and decode the JSON request body.
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
if (!isset($input['totp_code']) || strlen(trim($input['totp_code'])) !== 6 || !ctype_digit(trim($input['totp_code']))) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "A valid 6-digit TOTP code is required"]);
|
||||
exit;
|
||||
}
|
||||
use RobThree\Auth\Algorithm;
|
||||
use RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider;
|
||||
|
||||
$totpCode = trim($input['totp_code']);
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (empty($username)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Username not found in session"]);
|
||||
exit;
|
||||
}
|
||||
try {
|
||||
// standardized error helper
|
||||
function respond($status, $code, $message, $data = []) {
|
||||
http_response_code($code);
|
||||
echo json_encode([
|
||||
'status' => $status,
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current user's TOTP secret from users.txt.
|
||||
*
|
||||
* @param string $username
|
||||
* @return string|null The decrypted TOTP secret or null if not found.
|
||||
*/
|
||||
function getUserTOTPSecret($username) {
|
||||
global $encryptionKey;
|
||||
// Define the path to your users file.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
// Rate‑limit TOTP attempts
|
||||
if (!isset($_SESSION['totp_failures'])) {
|
||||
$_SESSION['totp_failures'] = 0;
|
||||
}
|
||||
if ($_SESSION['totp_failures'] >= 5) {
|
||||
respond('error', 429, 'Too many TOTP attempts. Please try again later.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get a user's role from users.txt
|
||||
*/
|
||||
function getUserRole(string $username): ?string {
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) return null;
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 3 && $parts[0] === $username) {
|
||||
return trim($parts[2]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
// Assuming format: username:hashedPassword:role:encryptedTOTPSecret
|
||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||
return decryptData($parts[3], $encryptionKey);
|
||||
}
|
||||
|
||||
// Must be authenticated or pending TOTP
|
||||
if (
|
||||
!(
|
||||
(isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true)
|
||||
|| isset($_SESSION['pending_login_user'])
|
||||
)
|
||||
) {
|
||||
respond('error', 403, 'Not authenticated');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Retrieve the user's TOTP secret.
|
||||
$totpSecret = getUserTOTPSecret($username);
|
||||
if (!$totpSecret) {
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
respond('error', 403, 'Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Parse & validate input
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
$code = trim($input['totp_code'] ?? '');
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
respond('error', 400, 'A valid 6-digit TOTP code is required');
|
||||
}
|
||||
|
||||
// LOGIN flow (Basic‑Auth or OIDC)
|
||||
if (isset($_SESSION['pending_login_user'])) {
|
||||
$username = $_SESSION['pending_login_user'];
|
||||
$totpSecret = $_SESSION['pending_login_secret'];
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new GoogleChartsQrCodeProvider(), // QR code provider
|
||||
'FileRise', // issuer
|
||||
6, // number of digits
|
||||
30, // period in seconds
|
||||
Algorithm::Sha1 // Correct enum case name from your enum
|
||||
);
|
||||
|
||||
if (!$tfa->verifyCode($totpSecret, $code)) {
|
||||
$_SESSION['totp_failures']++;
|
||||
respond('error', 400, 'Invalid TOTP code');
|
||||
}
|
||||
|
||||
// success → complete login
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $username;
|
||||
$_SESSION['isAdmin'] = (getUserRole($username) === "1");
|
||||
$_SESSION['folderOnly'] = loadUserPermissions($username);
|
||||
|
||||
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'], $_SESSION['totp_failures']);
|
||||
|
||||
respond('ok', 200, 'Login successful');
|
||||
}
|
||||
|
||||
// SETUP‑VERIFICATION flow
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
respond('error', 400, 'Username not found in session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: retrieve the user's TOTP secret from users.txt
|
||||
*/
|
||||
function getUserTOTPSecret(string $username): ?string {
|
||||
global $encryptionKey;
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) return null;
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||
return decryptData($parts[3], $encryptionKey);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$totpSecret = getUserTOTPSecret($username);
|
||||
if (!$totpSecret) {
|
||||
respond('error', 500, 'TOTP secret not found. Please set up TOTP again.');
|
||||
}
|
||||
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new GoogleChartsQrCodeProvider(), // QR code provider
|
||||
'FileRise', // issuer
|
||||
6, // number of digits
|
||||
30, // period in seconds
|
||||
Algorithm::Sha1 // Correct enum case name from your enum
|
||||
);
|
||||
|
||||
if (!$tfa->verifyCode($totpSecret, $code)) {
|
||||
$_SESSION['totp_failures']++;
|
||||
respond('error', 400, 'Invalid TOTP code');
|
||||
}
|
||||
|
||||
// success
|
||||
unset($_SESSION['totp_failures']);
|
||||
respond('ok', 200, 'TOTP successfully verified');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// log error internally, then generic response
|
||||
error_log("totp_verify error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "TOTP secret not found. Please try setting up TOTP again."]);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'code' => 500,
|
||||
'message' => 'Internal server error'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify the provided TOTP code.
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
|
||||
if (!$tfa->verifyCode($totpSecret, $totpCode)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid TOTP code."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// If needed, you could update a flag or store the confirmation in the user record here.
|
||||
|
||||
// Return a successful response.
|
||||
echo json_encode(["success" => true, "message" => "TOTP successfully verified."]);
|
||||
?>
|
||||
}
|
||||
@@ -33,6 +33,9 @@ if (!is_array($data)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Retrieve new header title, sanitize if necessary.
|
||||
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
|
||||
|
||||
// Validate and sanitize OIDC configuration.
|
||||
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
|
||||
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
|
||||
@@ -54,8 +57,9 @@ $disableOIDCLogin = isset($data['disableOIDCLogin']) ? filter_var($data['disable
|
||||
// Retrieve the global OTPAuth URL (new field). If not provided, default to an empty string.
|
||||
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
|
||||
|
||||
// Prepare configuration array.
|
||||
// Prepare configuration array including the header title.
|
||||
$configUpdate = [
|
||||
'header_title' => $headerTitle, // New field for the header title
|
||||
'oidc' => [
|
||||
'providerUrl' => $oidcProviderUrl,
|
||||
'clientId' => $oidcClientId,
|
||||
@@ -79,15 +83,10 @@ $encryptedContent = encryptData($plainTextConfig, $encryptionKey);
|
||||
|
||||
// Attempt to write the new configuration.
|
||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||
// Log the error.
|
||||
error_log("updateConfig.php: Initial write failed, attempting to delete the old configuration file.");
|
||||
|
||||
// Delete the old file.
|
||||
if (file_exists($configFile)) {
|
||||
unlink($configFile);
|
||||
}
|
||||
|
||||
// Try writing again.
|
||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||
error_log("updateConfig.php: Failed to write configuration even after deletion.");
|
||||
http_response_code(500);
|
||||
|
||||
@@ -40,16 +40,39 @@ if (file_exists($permissionsFile)) {
|
||||
$existingPermissions = [];
|
||||
}
|
||||
|
||||
// Load user roles from the users file (similar to getUsers.php)
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$userRoles = [];
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 3) {
|
||||
// Validate username format:
|
||||
if (preg_match(REGEX_USER, $parts[0])) {
|
||||
// Use a lowercase key for consistency.
|
||||
$userRoles[strtolower($parts[0])] = trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through each permission update.
|
||||
foreach ($permissions as $perm) {
|
||||
// Ensure username is provided.
|
||||
if (!isset($perm['username'])) continue;
|
||||
$username = $perm['username'];
|
||||
|
||||
// Look up the user's role from the users file.
|
||||
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
||||
|
||||
// Skip updating permissions for admin users.
|
||||
if (strtolower($username) === "admin") continue;
|
||||
if ($role === "1") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update permissions: default any missing value to false.
|
||||
$existingPermissions[$username] = [
|
||||
$existingPermissions[strtolower($username)] = [
|
||||
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
||||
'readOnly' => isset($perm['readOnly']) ? (bool)$perm['readOnly'] : false,
|
||||
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
||||
|
||||
26
upload.php
@@ -18,13 +18,14 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['disableUpload']) && $userPermissions['disableUpload'] === true) {
|
||||
http_response_code(403); // Return a 403 Forbidden status.
|
||||
echo json_encode(["error" => "Disabled upload users are not allowed to upload."]);
|
||||
exit();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +63,18 @@ if (isset($_POST['resumableChunkNumber'])) {
|
||||
$totalSize = intval($_POST['resumableTotalSize']);
|
||||
$resumableIdentifier = $_POST['resumableIdentifier']; // unique file identifier
|
||||
$resumableFilename = $_POST['resumableFilename'];
|
||||
|
||||
|
||||
// First, strip directory components.
|
||||
$resumableFilename = urldecode(basename($_POST['resumableFilename']));
|
||||
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid file name: " . $resumableFilename]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = isset($_POST['folder']) ? trim($_POST['folder']) : 'root';
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
exit;
|
||||
}
|
||||
@@ -164,7 +175,7 @@ if (isset($_POST['resumableChunkNumber'])) {
|
||||
// ------------- Full Upload (Non-chunked) -------------
|
||||
// Validate folder name input.
|
||||
$folder = isset($_POST['folder']) ? trim($_POST['folder']) : 'root';
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
exit;
|
||||
}
|
||||
@@ -186,10 +197,12 @@ if (isset($_POST['resumableChunkNumber'])) {
|
||||
$metadataCollection = []; // key: folder path, value: metadata array
|
||||
$metadataChanged = []; // key: folder path, value: boolean
|
||||
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
// Use a Unicode-enabled pattern to allow special characters.
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
foreach ($_FILES["file"]["name"] as $index => $fileName) {
|
||||
$safeFileName = basename($fileName);
|
||||
// First, ensure we only work with the base filename to avoid traversal issues.
|
||||
$safeFileName = trim(urldecode(basename($fileName)));
|
||||
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
||||
echo json_encode(["error" => "Invalid file name: " . $fileName]);
|
||||
exit;
|
||||
@@ -215,6 +228,7 @@ if (isset($_POST['resumableChunkNumber'])) {
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, $folderPath) . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
// Reapply basename to the relativePath to get the final safe file name.
|
||||
$safeFileName = basename($relativePath);
|
||||
}
|
||||
// --- End Minimal Folder/Subfolder Logic ---
|
||||
|
||||
151
uploadToSharedFolder.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
// uploadToSharedFolder.php
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
// Only accept POST requests.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure the share token is provided.
|
||||
if (empty($_POST['token'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Missing share token."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = trim($_POST['token']);
|
||||
|
||||
// Load the share folder records.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Share record not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Invalid share token."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$record = $shareLinks[$token];
|
||||
|
||||
// Check if the share link is expired.
|
||||
if (time() > $record['expires']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "This share link has expired."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure that uploads are allowed for this share.
|
||||
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "File uploads are not allowed for this share."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check that a file was uploaded.
|
||||
if (!isset($_FILES['fileToUpload'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "No file was uploaded."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$fileUpload = $_FILES['fileToUpload'];
|
||||
|
||||
// Check for upload errors.
|
||||
if ($fileUpload['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "File upload error. Code: " . $fileUpload['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Enforce a maximum file size (e.g. 50MB).
|
||||
$maxSize = 50 * 1024 * 1024; // 50MB
|
||||
if ($fileUpload['size'] > $maxSize) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "File size exceeds allowed limit."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Define allowed file extensions.
|
||||
$allowedExtensions = ['jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx','mp4','webm','mp3'];
|
||||
$uploadedName = basename($fileUpload['name']);
|
||||
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExtensions)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "File type not allowed."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine the target folder from the share record.
|
||||
$folder = trim($record['folder'], "/\\");
|
||||
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
$realTargetFolder = realpath($targetFolder);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
|
||||
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Shared folder not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generate a new filename to avoid collisions.
|
||||
// A unique prefix (using uniqid) is prepended to help with uniqueness and traceability.
|
||||
$newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||
$targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename;
|
||||
|
||||
// Move the uploaded file securely.
|
||||
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to move the uploaded file."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Metadata Update for Shared Upload ---
|
||||
$metadataKey = ($folder === '' || $folder === 'root') ? "root" : $folder;
|
||||
// Sanitize the metadata file name.
|
||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||
$metadataFile = META_DIR . $metadataFileName;
|
||||
|
||||
// Load existing metadata if available.
|
||||
$metadataCollection = [];
|
||||
if (file_exists($metadataFile)) {
|
||||
$data = file_get_contents($metadataFile);
|
||||
$metadataCollection = json_decode($data, true);
|
||||
if (!is_array($metadataCollection)) {
|
||||
$metadataCollection = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Set upload date using your defined format.
|
||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||
|
||||
// Since there is no logged-in user for public share uploads,
|
||||
$uploader = "Outside Share";
|
||||
|
||||
// Update metadata for the new file.
|
||||
if (!isset($metadataCollection[$newFilename])) {
|
||||
$metadataCollection[$newFilename] = [
|
||||
"uploaded" => $uploadedDate,
|
||||
"uploader" => $uploader
|
||||
];
|
||||
}
|
||||
|
||||
// Save the metadata.
|
||||
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
|
||||
// --- End Metadata Update ---
|
||||
|
||||
// Optionally, set a flash message in session.
|
||||
$_SESSION['upload_message'] = "File uploaded successfully.";
|
||||
|
||||
// Redirect back to the shared folder view, refreshing the file listing.
|
||||
header("Location: shareFolder.php?token=" . urlencode($token));
|
||||
exit;
|
||||
?>
|
||||
@@ -1,7 +1,7 @@
|
||||
<IfModule mod_php7.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
Options -Indexes
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
Options -Indexes
|
||||
@@ -1,3 +1,3 @@
|
||||
<Files "users.txt">
|
||||
Require all denied
|
||||
</Files>
|
||||
Require all denied
|
||||
</Files>
|
||||