Compare commits

...

74 Commits

Author SHA1 Message Date
Ryan
06b3f28df0 New fetchWithCsrf with fallback for session change. start.sh session directory added. 2025-04-23 09:53:21 -04:00
Ryan
89f124250c Fixed totp isAdmin when session is missing but remember_me_token cookie present 2025-04-23 02:30:43 -04:00
Ryan
66f13fd6a7 dockerignore cleanup 2025-04-23 01:50:24 -04:00
Ryan
a81d9cb940 Enhance remember me 2025-04-23 01:47:27 -04:00
Ryan
13b8871200 docker: remove symlink add alias for uploads folder 2025-04-22 22:28:06 -04:00
Ryan
2792c05c1c docker: consolidate config & security improvements 2025-04-22 21:34:21 -04:00
Ryan
6ccfc88acb Composer & WebDAV readme changes 2025-04-22 19:27:53 -04:00
Ryan
7f1d59b33a add acknowledgements to README and LICENSE 2025-04-22 19:06:33 -04:00
Ryan
e4e8b108d2 Add permissions to workflow 2025-04-22 18:11:42 -04:00
Ryan
242661a9c9 New Admin Panel settings (enableWebDAV & shareMaxUploadSize) 2025-04-22 17:11:19 -04:00
Ryan
ca3e2f316c PUID/PGID changes 2025-04-22 08:19:10 -04:00
Ryan
6ff4aa5f34 support PUID/PGID env vars & update Unraid template 2025-04-22 08:06:29 -04:00
Ryan
1eb54b8e6e Updated WebDav and curl readme 2025-04-21 13:23:54 -04:00
Ryan
4a6c424540 Add sabre/dav to dependencies and fix resumable.js url 2025-04-21 11:57:01 -04:00
Ryan
d23d5b7f3f Added WebDAV Support & curl 2025-04-21 11:12:42 -04:00
Ryan
a48ba09f02 Add WebDAV support with user folderOnly restrictions 2025-04-21 10:39:55 -04:00
Ryan
61357af203 Fetch URL fixes, Extended “Remember Me” cookie behavior, submitLogin() overhaul 2025-04-19 17:53:01 -04:00
Ryan
e390a35e8a Gallery View add selection actions and search filtering 2025-04-18 02:58:30 -04:00
Ryan
7e50ba1f70 test pipeline 2025-04-18 00:52:39 -04:00
Ryan
cc41f8cc95 update sync 2025-04-18 00:51:51 -04:00
Ryan
7c31b9689f update changelog & test pipeline 2025-04-18 00:43:33 -04:00
Ryan
461921b7bc Remember me adjustment 2025-04-18 00:40:17 -04:00
Ryan
3b58123584 User Panel added API Docs link 2025-04-17 06:45:00 -04:00
Ryan
cd9d7eb0ba HTML wrapper that pulls in Redoc from the CDN 2025-04-17 06:28:05 -04:00
Ryan
c0c8d68dc4 mark openapi.json & api.html as documentation 2025-04-17 06:11:27 -04:00
Ryan
2dfcb4062f Generate OpenAPI spec and API HTML docs 2025-04-17 06:04:15 -04:00
Ryan
d839b3ac1c Fix folder share gallery view link 2025-04-17 02:38:32 -04:00
Ryan
766458f707 Dockerfile, custom-php.ini & start.sh moved into main repo 2025-04-17 02:10:46 -04:00
Ryan
22cce5a898 Overhaul networkUtils and expand auth 2025-04-17 01:20:18 -04:00
Ryan
75d3bf5a9b Refactor fixes and adjustments 2025-04-16 17:15:59 -04:00
Ryan
4ec4ba832f Changes 4/16 Refactor API endpoints and modularize controllers and models 2025-04-16 13:04:36 -04:00
Ryan
97b67593bc remove 2025-04-16 11:43:29 -04:00
Ryan
ec5c3fc452 Refactor API endpoints and modularize controllers and models 2025-04-16 11:40:17 -04:00
Ryan
853d8835d9 Adjust Gallery View max columns based on screen size & Adjust headerTitle to update globally 2025-04-15 16:07:20 -04:00
Ryan
1d36d002c6 Force resumable chunk size & fix chunk cleanup 2025-04-14 16:58:12 -04:00
Ryan
844976ef89 Updated zoom-in, zoom-out, rotate-left & rotate-right buttons to match prev & next 2025-04-14 13:06:30 -04:00
Ryan
66e0d7ecbe filePreview enhancements and ensure gallery view toggle displays 2025-04-14 03:42:24 -04:00
Ryan
a5fbcdef88 Fix Gallery View: medium screen devices get 3 max columns and small screen devices 2 max columns. 2025-04-14 00:29:30 -04:00
Ryan
a897d1734f Gallery view enhancements 2025-04-13 20:48:34 -04:00
Ryan
a9c4200827 Extend i18n support: Add new translation keys for Download and Share modals 2025-04-13 18:38:21 -04:00
Ryan
97559873dc Added translations and data attributes for almost all user-facing text 2025-04-13 15:22:52 -04:00
Ryan
0683b27534 New Admin section Header Settings to change Header Title. 2025-04-13 05:51:19 -04:00
Ryan
49c42e8096 RecoveryCodeModal i18n 2025-04-13 04:17:32 -04:00
Ryan
ed39e112a9 more i18n.js keys added 2025-04-13 03:55:51 -04:00
Ryan
25edab923a Decreased header size some more and clickable logo 2025-04-13 02:52:14 -04:00
Ryan
b8ae3c4402 Advanced/Basic search button as material icon on same row as search bar. 2025-04-12 16:31:05 -04:00
Ryan
fb537b1d61 Change search box text when enabling advanced search. 2025-04-12 15:36:23 -04:00
Ryan
90439022e3 Gallery View Toggle button moved to header 2025-04-12 15:16:59 -04:00
Ryan
b4c8738b8a advance search readme added 2025-04-12 14:18:14 -04:00
Ryan
e193bf9b13 Advanced Search Implementation 2025-04-12 14:16:52 -04:00
Ryan
a70d8fc2c7 v1.1.2 2025-04-12 12:52:15 -04:00
Ryan
d9f69d7917 Fuse.js Integration for Indexed Real-Time Searching & Dependencies added 2025-04-12 12:46:28 -04:00
Ryan
28ac23c2f6 Fix totp_setup.php to use header-based CSRF token verification 2025-04-11 23:40:27 -04:00
Ryan
b06c49f213 ensure consistent session behavior 2025-04-11 22:36:43 -04:00
Ryan
8553efabc1 Upgrade dependencies: update robthree/twofactorauth to v3 and endroid/qr-code to v5; update TOTP integration (namespace, enum, QR provider) accordingly 2025-04-11 18:41:44 -04:00
Ryan
81a08ffd5b fix missing 2025-04-11 10:55:33 -04:00
Ryan
296dae96a5 regex configuration constants 2025-04-11 10:44:26 -04:00
Ryan
337f529afd fix drag-drop, UI glitches, & update validation 2025-04-11 03:21:09 -04:00
Ryan
4360f2830a new filetag endpoint and config.php update 2025-04-10 10:33:13 -04:00
Ryan
894cc938a5 i18n-title for folder buttons 2025-04-10 04:27:40 -04:00
Ryan
01801ba950 fix gallery view filename wrapping 2025-04-10 03:58:26 -04:00
Ryan
5b592575a4 adjust card mouseover start position 2025-04-10 03:18:08 -04:00
Ryan
7cce03d092 Use create folder material icon and increase version 2025-04-10 02:59:04 -04:00
Ryan
ff92a6d26c Reduce header height & create folder material icon 2025-04-10 02:29:48 -04:00
Ryan
4fa5faa2bf Shift Key Multi‑Selection & Total Files and File Size 2025-04-10 00:45:35 -04:00
Ryan
98850a7c65 Update 2025-04-09 02:20:16 -04:00
Ryan
15bac15c33 update readme 2025-04-09 01:48:56 -04:00
Ryan
b2ff3efb3b Folder sharing added 2025-04-09 01:46:07 -04:00
Ryan
b9ce3f92a4 Progress modal for handleExtractZip 2025-04-08 21:04:44 -04:00
Ryan
f65b151bc3 Progress Modal on download buttons 2025-04-08 20:30:17 -04:00
Ryan
703c93db25 semi-complete internationalization 2025-04-08 18:49:30 -04:00
Ryan
d0353b137b German translation added 2025-04-08 18:48:22 -04:00
Ryan
a6c4c1d39c Start i18n Integration 2025-04-08 18:40:01 -04:00
Ryan
7aa4fe142a readme update for user permissions 2025-04-08 02:08:10 -04:00
146 changed files with 16508 additions and 5975 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
# dockerignore
.git
.gitignore
.github
.github/**
Dockerfile*
resources/
node_modules/
*.log
tmp/
.env
.vscode/
.DS_Store

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
public/api.html linguist-documentation
public/openapi.json linguist-documentation
resources/ export-ignore
.github/ export-ignore

43
.github/workflows/sync-changelog.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Sync Changelog to Docker Repo
on:
push:
paths:
- 'CHANGELOG.md'
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout FileRise
uses: actions/checkout@v4
with:
path: file-rise
- name: Checkout filerise-docker
uses: actions/checkout@v4
with:
repository: error311/filerise-docker
token: ${{ secrets.PAT_TOKEN }}
path: docker-repo
- name: Copy CHANGELOG.md
run: |
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md
- name: Commit & push
working-directory: docker-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore: sync CHANGELOG.md from FileRise"
git push origin main
fi

View File

@@ -1,5 +1,425 @@
# Changelog # Changelog
## Changes 4/23/2025 1.2.4
**AuthModel**
- **Added** `validateRememberToken(string $token): ?array`
- Reads and decrypts `persistent_tokens.json`
- Verifies token exists and hasnt expired
- Returns stored payload (`username`, `expiry`, `isAdmin`, etc.) or `null` if invalid
**authController (checkAuth)**
- **Enhanced** “remember-me” re-login path at top of `checkAuth()`
- Calls `AuthModel::validateRememberToken()` when session is missing but `remember_me_token` cookie present
- Repopulates `$_SESSION['authenticated']`, `username`, `isAdmin`, `folderOnly`, `readOnly`, `disableUpload` from payload
- Regenerates session ID and CSRF token, then immediately returns JSON and exits
- **Updated** `userController.php`
- Fixed totp isAdmin when session is missing but `remember_me_token` cookie present
- **loadCsrfToken()**
- Now reads `X-CSRF-Token` response header first, falls back to JSON `csrf_token` if header absent
- Updates `window.csrfToken`, `window.SHARE_URL`, and `<meta>` tags with the new values
- **fetchWithCsrf(url, options)**
- Sends `credentials: 'include'` and current `X-CSRF-Token` on every request
- Handles “soft-failure” JSON (`{ csrf_expired: true, csrf_token }`): updates token and retries once without a 403 in DevTools
- On HTTP 403 fallback: reads new token from header or `/api/auth/token.php`, updates token, and retries once
- **start.sh**
- Session directory setup
## Changes 4/22/2025 v1.2.3
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
- New `PUID` and `PGID` config options in the Unraid Community Apps template
- Dockerfile:
- startup (`start.sh`) now runs as root to write `/etc/php` & `/etc/apache2` configs
- `wwwdata` user is remapped at buildtime to the supplied `PUID:PGID`, then Apache drops privileges to that user
- Unraid template: removed recommendation to use `--user`; replaced with `PUID`, `PGID`, and `Container Port` variables
- “Permission denied” errors when forcing `--user 99:100` on Unraid by ensuring startup runs as root
- Dockerfile silence group issue
- `enableWebDAV` toggle in Admin Panel (default: disabled)
- **Admin Panel enhancements**
- New `enableWebDAV` boolean setting
- New `sharedMaxUploadSize` numeric setting (bytes)
- **Shared Folder upload size**
- `sharedMaxUploadSize` is now enforced in `FolderModel::uploadToSharedFolder`
- Upload form header on sharedfolder page dynamically shows “(X MB max size)”
- **API updates**
- `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize`
- Updated `AdminModel` & `AdminController` to persist and validate new settings
- Enhanced `shareFolder()` view to pull from admin config and format the maxuploadsize label
- Restored the MIT license copyright line that was inadvertently removed.
- Move .htaccess to public folder this was mistake since API refactor.
- gitattributes to ignore resources/ & .github/ on export
- Hardened `Dockerfile` permissions: all code files owned by `root:www-data` (dirs `755`, files `644`), only `uploads/`, `users/` and `metadata/` are writable by `www-data` (`775`)
- `.dockerignore` entry to exclude the `.github` directory from build context
- `start.sh`:
- Creates and secures `metadata/log` for Apache logs
- Dynamically creates and sets permissions on `uploads`, `users`, and `metadata` directories at startup
- Apache VirtualHost updated to redirect `ErrorLog` and `CustomLog` into `/var/www/metadata/log`
- docker: remove symlink add alias for uploads folder
---
## Changes 4/21/2025 v1.2.2
### Added
- **`src/webdav/CurrentUser.php`**
Introduces a `CurrentUser` singleton to capture and expose the authenticated WebDAV username for use in other components.
### Changed
- **`src/webdav/FileRiseDirectory.php`**
Constructor now takes three parameters (`$path`, `$user`, `$folderOnly`).
Implements “folderonly” mode: nonadmin users only see their own subfolder under the uploads root.
Passes the current user through to `FileRiseFile` so that uploads/deletions are attributed correctly.
- **`src/webdav/FileRiseFile.php`**
Uses `CurrentUser::get()` when writing metadata to populate the `uploader` field.
Metadata helper (`updateMetadata`) now records both upload and modified timestamps along with the actual username.
- **`public/webdav.php`**
Adds a headershim at the top to pull BasicAuth credentials out of `Authorization` for all HTTP methods.
In the auth callback, sets the `CurrentUser` for the rest of the request.
- Admins & unrestricted users see the full `/uploads` directory.
- “Folderonly” users are scoped to `/uploads/{username}`.
Configures SabreDAV with the new `FileRiseDirectory($rootPath, $user, $folderOnly)` signature and sets the base URI to `/webdav.php/`.
## Changes 4/19/2025 v1.2.1
- **Extended “Remember Me” cookie behavior**
In `AuthController::finalizeLogin()`, after setting `remember_me_token` reissued the PHP session cookie with the same 30day expiry and called `session_regenerate_id(true)`.
- **Fetch URL fixes**
Changed all frontend `fetch("api/…")` calls to absolute paths `fetch("/api/…")` to avoid relativepath 404/403 issues.
- **CSRF token refresh**
Updated `submitLogin()` and both TOTP submission handlers to `async/await` a fresh CSRF token from `/api/auth/token.php` (with `credentials: "include"`) immediately before any POST.
- **submitLogin() overhaul**
Refactored to:
1. Fetch CSRF
2. POST credentials to `/api/auth/auth.php`
3. On `totp_required`, refetch CSRF *again* before calling `openTOTPLoginModal()`
4. Handle full logins vs. TOTP flows cleanly.
- **TOTP handlers update**
In both the “Confirm TOTP” button flow and the autosubmit on 6digit input:
- Refreshed CSRF token before every `/api/totp_verify.php` call
- Checked `response.ok` before parsing JSON
- Improved `.catch` error handling
- **verifyTOTP() endpoint enhancement**
Inside the **pendinglogin** branch of `verifyTOTP()`:
- Pulled `$_SESSION['pending_login_remember_me']`
- If true, wrote the persistent token store, set `remember_me_token`, reissued the session cookie, and regenerated the session ID
- Cleaned up pending session variables
---
## Changes 4/18/2025
### fileListView.js
- Seed and persist `itemsPerPage` from `localStorage`
- Use `window.itemsPerPage` for pagination in gallery
- Enable search input filtering in gallery mode
- Always rerender the viewtoggle button on gallery load
- Restore percard action buttons (download, edit, rename, share)
- Assign real `value` to checkboxes and call `updateFileActionButtons()` on change
- Update `changePage` and `changeItemsPerPage` to respect `viewMode`
### fileTags.js
- Import `renderFileTable` and `renderGalleryView`
- Rerender the list after saving a singlefile tag
- Rerender the list after saving multifile tags
---
## Changes 4/17/2025
- Generate OpenAPI spec and API HTML docs
- Fully autogenerated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
- .gitattributes added to mark (`openapi.json`) & (`api.html`) as documentation.
- User Panel added API Docs link.
- Adjusted remember_me_token.
- Test pipeline
---
## Changes 4/16 Refactor API endpoints and modularize controllers and models
- Reorganized project structure to separate API logic into dedicated controllers and models:
- Created adminController, userController, fileController, folderController, uploadController, and authController.
- Created corresponding models (AdminModel, UserModel, FileModel, FolderModel, UploadModel, AuthModel) for business logic.
- Consolidated API endpoints under the /public/api folder with subfolders for admin, auth, file, folder, and upload endpoints.
- Added inline OpenAPI annotations to document key endpoints (e.g., getConfig.php, updateConfig.php) for improved API documentation.
- Updated configuration retrieval and update logic in AdminModel and AdminController to handle OIDC and login option booleans consistently, fixing issues with basic auth settings not updating on the login page.
- Updated the client-side auth.js to correctly reference API endpoints (adjusted query selectors to reflect new document root) and load admin configuration from the updated API endpoints.
- Minor improvements to CSRF token handling, error logging, and overall code readability.
This refactor improves maintainability, testability, and documentation clarity across all API endpoints.
### Refactor fixes and adjustments
- Added fallback checks for disableFormLogin / disableBasicAuth / disableOIDCLogin when coming in either at the top level or under loginOptions.
- Updated auth.js to read and store the nested loginOptions booleans correctly in localStorage, then show/hide the BasicAuth and OIDC buttons as configured.
- Changed the logout controller to header("Location: /index.html?logout=1") so after /api/auth/logout.php it lands on the root index.html, not under /api/auth/.
- Switched your share modal code to use a leading slash ("/api/file/share.php") so it generates absolute URLs instead of relative /share.php.
- In the sharedfolder gallery, adjusted the clientside image path to point at /uploads/... instead of /api/folder/uploads/...
- Updated both AdminModel defaults and the AuthController to use the exact full path
- Network Utilities Overhaul swapped out the old fetch wrapper for one that always reads the raw response, tries to JSON.parse it, and then either returns the parsed object on ok or throws it on error.
- Adjusted your submitLogin .catch() to grab the thrown object (or string) and pass that through to showToast, so now “Invalid credentials” actually shows up.
- Pulled the common sessionsetup and “remember me” logic into two new helpers, finalizeLogin() (for AJAX/form/basic/TOTP) and finishBrowserLogin() (for OIDC redirects). That removed tons of duplication and ensures every path calls the same permissionloading code.
- Ensured that after you POST just a totp_code, we pick up pending_login_user/pending_login_secret, verify it, then immediately call finalizeLogin().
- Expanded checkAuth.php Response now returns all three flags—folderOnly, readOnly, and disableUpload so client can handle every permission.
- In auth.jss updateAuthenticatedUI(), write all three flags into localStorage whenever you land on the app (OIDC, basic or form). That guarantees consistent behavior across page loads.
- Made sure the OIDC handler reads the live config via AdminModel::getConfig() and pushes you through the TOTP flow if needed, then back to /index.html.
- Dockerfile, custom-php.ini & start.sh moved into main repo for easier onboarding.
- filerise-docker changed to dedicated CI/CD pipeline
---
## Changes 4/15/2025
- Adjust Gallery View max columns based on screen size
- Adjust headerTitle to update globally
## Changes 4/14/2025
- Fix Gallery View: medium screen devices get 3 max columns and small screen devices 2 max columns.
- Ensure gallery view toggle button displays after refresh page.
- Force resumable chunk size & fix chunk cleanup
### filePreview.js Enhancements
**Modal Layout Overhaul:**
- **Left Panel:** Holds zoom in/out controls at the top and the "prev" button at the bottom.
- **Center Panel:** Always centers the preview image.
- **Right Panel:** Contains rotate left/right controls at the top and the "next" button at the bottom.
**Consistent Control Presence:**
- Both left and right panels are always included. When theres only one image, placeholders are inserted in place of missing navigation buttons to ensure the image remains centered and that rotate controls are always visible.
**Improved Transform Behavior:**
- Transformation values (scale and rotation) are reset on each navigation event, ensuring predictable behavior and consistent presentation.
---
## 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
- **Slider Integration:**
- Added a slider UI (range input, label, and value display) directly above the gallery grid.
- The slider allows users to adjust the number of columns in the gallery from 1 to 6.
- **Dynamic Grid Updates:**
- The gallery grids CSS is updated in real time via the sliders value by setting the grid-template-columns property.
- As the slider value changes, the layout instantly reflects the new column count.
- **Dynamic Image Resizing:**
- Introduced a helper function (getMaxImageHeight) that calculates the maximum image height based on the current column count.
- The max height of each image is updated immediately when the slider is adjusted to create a more dynamic display.
- **Image Caching:**
- Implemented an image caching mechanism using a global window.imageCache object.
- Images are cached on load (via an onload event) to prevent unnecessary reloading, improving performance.
- **Event Handling:**
- The sliders event listener is set up to update both the gallery grid layout and the dimensions of the thumbnails dynamically.
- Share button event listeners remain attached for proper functionality across the updated gallery view.
- **Input Validation & Security:**
- Used `filter_input()` to sanitize and validate incoming GET parameters (token, pass, page).
- Validated file system paths using `realpath()` and ensured the shared folder lies within `UPLOAD_DIR`.
- Escaped all dynamic outputs with `htmlspecialchars()` to prevent XSS.
- **Share Link Verification:**
- Loaded and validated share records from the JSON file.
- Handled expiration and password protection (with proper HTTP status codes for errors).
- **Pagination:**
- Implemented pagination by slicing the full file list into a limited number of files per page (default of 10).
- Calculated total pages and current page to create navigation links.
- **View Toggle (List vs. Gallery):**
- Added a toggle button that switches between a traditional list view and a gallery view.
- Maintained two separate view containers (`#listViewContainer` and `#galleryViewContainer`) to support this switching.
- **Gallery View with Image Caching:**
- For the gallery view, implemented a JavaScript function that creates a grid of image thumbnails.
- Each image uses a cache-busting query string on first load and caches its URL in a global `window.imageCache` for subsequent renders.
- **Persistent Pagination Controls:**
- Moved the pagination controls outside the individual view containers so that they remain visible regardless of the selected view.
---
## 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 clientside 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 isnt 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 inarray .filter(). This ensures that every search—realtime by user input—is powered by Fuse.jss 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 Unicodeenabled 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 MultiSelection 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 Shiftclick 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 humanreadable 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 servers 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 users 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 ## Changes 4/7/2025 v1.0.9
- TOTP one time recovery code added - TOTP one time recovery code added

View File

@@ -11,6 +11,7 @@ Thank you for your interest in contributing to FileRise! We appreciate your help
- [Coding Guidelines](#coding-guidelines) - [Coding Guidelines](#coding-guidelines)
- [Documentation](#documentation) - [Documentation](#documentation)
- [Questions and Support](#questions-and-support) - [Questions and Support](#questions-and-support)
- [Adding New Language Translations](#adding-new-language-translations)
## Getting Started ## 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** 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** 4. **Configuration**
Copy any example configuration files (if provided) and adjust them as needed for your local setup. 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). Were here to help and appreciate your contributions. 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). Were 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! Thank you for helping to improve FileRise and happy coding!

90
Dockerfile Normal file
View File

@@ -0,0 +1,90 @@
# syntax=docker/dockerfile:1.4
#############################
# Source Stage copy your FileRise app
#############################
FROM ubuntu:24.04 AS appsource
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* # clean up apt cache
RUN mkdir -p /var/www && rm -f /var/www/html/index.html
COPY . /var/www
#############################
# Composer Stage install PHP dependencies
#############################
FROM composer:2 AS composer
WORKDIR /app
COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./
RUN composer install --no-dev --optimize-autoloader # production-ready autoloader
#############################
# Final Stage runtime image
#############################
FROM ubuntu:24.04
LABEL by=error311
ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \
LC_ALL=C.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 TERM=xterm \
UPLOAD_MAX_FILESIZE=5G POST_MAX_SIZE=5G TOTAL_UPLOAD_SIZE=5G \
PERSISTENT_TOKENS_KEY=default_please_change_this_key \
PUID=99 PGID=100
# Install Apache, PHP, and required extensions
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
ca-certificates curl git openssl && \
apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
# Remap www-data to the PUID/PGID provided for safe bind mounts
RUN set -eux; \
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u "${PUID}" www-data; fi; \
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g "${PGID}" www-data 2>/dev/null || true; fi; \
usermod -g "${PGID}" www-data
# Copy config, code, and vendor
COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
COPY --from=appsource /var/www /var/www
COPY --from=composer /app/vendor /var/www/vendor
# Secure permissions: code read-only, only data dirs writable
RUN chown -R root:www-data /var/www && \
find /var/www -type d -exec chmod 755 {} \; && \
find /var/www -type f -exec chmod 644 {} \; && \
mkdir -p /var/www/public/uploads /var/www/users /var/www/metadata && \
chown -R www-data:www-data /var/www/public/uploads /var/www/users /var/www/metadata && \
chmod -R 775 /var/www/public/uploads /var/www/users /var/www/metadata # writable upload areas
# Apache site configuration
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/public
Alias /uploads/ /var/www/uploads/
<Directory "/var/www/uploads/">
Options -Indexes
AllowOverride None
Require all granted
</Directory>
<Directory "/var/www/public">
AllowOverride All
Require all granted
DirectoryIndex index.html
</Directory>
ErrorLog /var/www/metadata/log/error.log
CustomLog /var/www/metadata/log/access.log combined
</VirtualHost>
EOF
# Enable required modules
RUN a2enmod rewrite headers
EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
CMD ["/usr/local/bin/start.sh"]

View File

@@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2024 SeNS Copyright (c) 2024 SeNS
Copyright (c) 2025 FileRise
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -12,23 +12,31 @@ Upload, organize, and share files through a sleek web interface. **FileRise** is
--- ---
## Features at a Glance ## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
- 🚀 **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. - 🚀 **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.
- 🗂️ **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. - 🗂️ **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.
- 🗃️ **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.
- 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it headless from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl%C2%A0(WebDAV)) quickstart for examples. FolderOnly users are restricted to their personal directory, while admins and unrestricted users have full access.
- 📚 **API Documentation:** Fully autogenerated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
- 📝 **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. - 📝 **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.
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags (labels) and later find them easily. The global search bar filters by filename or tag, making it simple to locate that “important” document in seconds. Tag management is built-in create, reuse, or remove tags as needed. - 🏷️ **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.
- 🔒 **User Authentication, User Permissions & Sharing:** Secure your portal with username/password login. Supports multiple users create user accounts (admin UI provided) for family or team members. Basic user permissions such as User Folder Only, Read Only and Disable Upload. 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. Share files with others using one-time or expiring public links (with password protection if desired) convenient for sending files without exposing the whole app. - 🔒 **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.
- 🎨 **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. - 🎨 **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.
- 🌐 **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, French & German—please report any translation issues you encounter.
- 🗑️ **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. - 🗑️ **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.
- ⚙️ **Lightweight & Self-Contained:** FileRise runs on PHP 8.1+ with no external database required data is stored in files (users, metadata) for simplicity. Its 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. - ⚙️ **Lightweight & SelfContained:** FileRise runs on PHP 8.1+ with no external database required data is stored in files (users, metadata) for simplicity. Its a singlefolder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our prebuilt image for a hasslefree setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
(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.) (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.)
@@ -54,8 +62,6 @@ If you have Docker installed, you can get FileRise up and running in minutes:
docker pull error311/filerise-docker:latest docker pull error311/filerise-docker:latest
``` ```
*(For Apple Silicon (M1/M2) users, use --platform linux/amd64 tag until multi-arch support is added.)*
- **Run a container:** - **Run a container:**
``` bash ``` bash
@@ -111,7 +117,7 @@ git clone https://github.com/error311/FileRise.git
Place the files into your web servers directory (e.g., `/var/www/html/filerise`). It can be in a subfolder (just adjust the `BASE_URL` in config as below). Place the files into your web servers 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 wont be available. - **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.)
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist): - **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist):
@@ -141,6 +147,51 @@ Now navigate to the FileRise URL in your browser. On first load, youll be pro
--- ---
## Quickstart: Mount via WebDAV
Once FileRise is running, you must enable WebDAV in admin panel to access it.
```bash
# Linux (GVFS/GIO)
gio mount dav://demo@your-host/webdav.php/
# macOS (Finder → Go → Connect to Server…)
dav://demo@your-host/webdav.php/
```
### Windows (File Explorer)
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
- Choose a drive letter (e.g., `Z:`).
- In **Folder**, enter:
```text
https://your-host/webdav.php/
```
- Check **Connect using different credentials**, and enter your FileRise username and password.
- Click **Finish**. The drive will now appear under **This PC**.
> **Important:**
> Windows requires HTTPS (SSL) for WebDAV connections by default.
> If your server uses plain HTTP, you must adjust a registry setting:
>
> 1. Open **Registry Editor** (`regedit.exe`).
> 2. Navigate to:
>
> ```text
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
> ```
>
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
> 4. Set its value to `2`.
> 5. Restart the **WebClient** service or reboot your computer.
📖 For a full guide (including SSL setup, HTTP workaround, and troubleshooting), see the [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV).
---
## FAQ / Troubleshooting ## FAQ / Troubleshooting
- **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHPs `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. - **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHPs `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.
@@ -173,6 +224,33 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
--- ---
## 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)
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
### 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](https://github.com/23/resumable.js/)** (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.
---
## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
---
## License ## License
This project is open-source under the MIT License. That means youre free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back! This project is open-source under the MIT License. That means youre free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!

View File

@@ -1,86 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
// Determine if we are in setup mode:
// - Query parameter setup=1 is passed
// - And users.txt is either missing or empty (zero bytes or trimmed content is empty)
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) {
// Allow initial admin creation without session checks.
$setupMode = true;
} else {
$setupMode = false;
// In non-setup mode, check CSRF token and require admin privileges.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
}
// Get input data from JSON.
$data = json_decode(file_get_contents("php://input"), true);
$newUsername = trim($data["username"] ?? "");
$newPassword = trim($data["password"] ?? "");
// In setup mode, force the new user to be admin.
if ($setupMode) {
$isAdmin = "1";
} else {
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0"; // "1" for admin, "0" for regular user.
}
// Validate input.
if (!$newUsername || !$newPassword) {
echo json_encode(["error" => "Username and password required"]);
exit;
}
// Validate username using preg_match (allow letters, numbers, underscores, dashes, and spaces).
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $newUsername)) {
echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
exit;
}
// Ensure users.txt exists.
if (!file_exists($usersFile)) {
file_put_contents($usersFile, '');
}
// Check if username already exists.
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($existingUsers as $line) {
list($storedUser, $storedHash, $storedRole) = explode(':', trim($line));
if ($newUsername === $storedUser) {
echo json_encode(["error" => "User already exists"]);
exit;
}
}
// Hash the password.
$hashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
// Prepare new user line.
$newUserLine = $newUsername . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
// In setup mode, overwrite users.txt; otherwise, append to it.
if ($setupMode) {
file_put_contents($usersFile, $newUserLine);
} else {
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
}
echo json_encode(["success" => "User added successfully"]);
?>

262
auth.php
View File

@@ -1,262 +0,0 @@
<?php
require_once 'vendor/autoload.php';
require_once 'config.php';
// Only send the Content-Type header; CORS and related headers are handled via .htaccess.
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)) {
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;
}
/* --- OIDC Authentication Flow --- */
// 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)) {
$enc = file_get_contents($adminConfigFile);
$dec = decryptData($enc, $encryptionKey);
$cfg = $dec !== false ? json_decode($dec, true) : [];
} else {
$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,
$oidc_client_id,
$oidc_client_secret
);
$oidc->setRedirectURL($oidc_redirect_uri);
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;
$_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();
}
}
}
/* --- Fallback: Form-based Authentication --- */
$usersFile = USERS_DIR . USERS_FILE;
$maxAttempts = 5;
$lockoutTime = 30 * 60; // 30 minutes
$attemptsFile = USERS_DIR . 'failed_logins.json';
$failedLogFile = USERS_DIR . 'failed_login.log';
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
function loadFailedAttempts($file) {
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
if (is_array($data)) {
return $data;
}
}
return [];
}
function saveFailedAttempts($file, $data) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
}
$ip = $_SERVER['REMOTE_ADDR'];
$currentTime = time();
$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();
}
}
function authenticate($username, $password) {
global $usersFile, $encryptionKey;
if (!file_exists($usersFile)) {
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) < 3) continue;
if ($username === $parts[0] && password_verify($password, $parts[1])) {
$result = ['role' => $parts[2]];
$result['totp_secret'] = (isset($parts[3]) && !empty($parts[3]))
? decryptData($parts[3], $encryptionKey)
: null;
return $result;
}
}
return false;
}
$data = json_decode(file_get_contents("php://input"), true);
$username = trim($data["username"] ?? "");
$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)) {
http_response_code(400);
echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
exit();
}
$user = authenticate($username, $password);
if ($user !== false) {
if (!empty($user['totp_secret'])) {
// 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('FileRise');
$providedCode = trim($data['totp_code']);
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
echo json_encode(["error" => "Invalid TOTP code"]);
exit();
}
}
}
if (isset($failedAttempts[$ip])) {
unset($failedAttempts[$ip]);
saveFailedAttempts($attemptsFile, $failedAttempts);
}
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = ($user['role'] === "1");
$_SESSION["folderOnly"] = loadUserPermissions($username);
if ($rememberMe) {
$token = bin2hex(random_bytes(32));
$expiry = time() + (30 * 24 * 60 * 60);
$persistentTokens = [];
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
$persistentTokens = json_decode($decryptedContent, true);
if (!is_array($persistentTokens)) {
$persistentTokens = [];
}
}
$persistentTokens[$token] = [
"username" => $username,
"expiry" => $expiry,
"isAdmin" => ($_SESSION["isAdmin"] === true)
];
$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"],
"username" => $_SESSION["username"]
]);
} else {
if (isset($failedAttempts[$ip])) {
$failedAttempts[$ip]['count']++;
$failedAttempts[$ip]['last_attempt'] = $currentTime;
} else {
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
}
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"]);
}
?>

View File

@@ -1,99 +0,0 @@
<?php
// changePassword.php
require_once 'config.php';
header('Content-Type: application/json');
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$username = $_SESSION['username'] ?? '';
if (!$username) {
echo json_encode(["error" => "No username in session"]);
exit;
}
// CSRF token check.
$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;
}
// Get POST data.
$data = json_decode(file_get_contents("php://input"), true);
$oldPassword = trim($data["oldPassword"] ?? "");
$newPassword = trim($data["newPassword"] ?? "");
$confirmPassword = trim($data["confirmPassword"] ?? "");
// Validate input.
if (!$oldPassword || !$newPassword || !$confirmPassword) {
echo json_encode(["error" => "All fields are required."]);
exit;
}
if ($newPassword !== $confirmPassword) {
echo json_encode(["error" => "New passwords do not match."]);
exit;
}
// Path to users file.
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
echo json_encode(["error" => "Users file not found"]);
exit;
}
// Read current users.
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$userFound = false;
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// Expect at least 3 parts: username, hashed password, and role.
if (count($parts) < 3) {
// Skip invalid lines.
$newLines[] = $line;
continue;
}
$storedUser = $parts[0];
$storedHash = $parts[1];
$storedRole = $parts[2];
// Preserve TOTP secret if it exists.
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
if ($storedUser === $username) {
$userFound = true;
// Verify the old password.
if (!password_verify($oldPassword, $storedHash)) {
echo json_encode(["error" => "Old password is incorrect."]);
exit;
}
// Hash the new password.
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
// Rebuild the line with the new hash and preserve TOTP secret if present.
if ($totpSecret !== "") {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
} else {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
}
} else {
$newLines[] = $line;
}
}
if (!$userFound) {
echo json_encode(["error" => "User not found."]);
exit;
}
// Save updated users file.
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
echo json_encode(["success" => "Password updated successfully."]);
} else {
echo json_encode(["error" => "Could not update password."]);
}
?>

View File

@@ -1,70 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Check if users.txt is empty or doesn't exist.
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
// In production, you might log that the system is in setup mode.
error_log("checkAuth: users file not found or empty; entering setup mode.");
echo json_encode(["setup" => true]);
exit();
}
// Check session authentication.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["authenticated" => false]);
exit();
}
/**
* Helper function to get a user's role from users.txt.
* Returns the role as a string (e.g. "1") or null if not found.
*/
function getUserRole($username) {
global $usersFile;
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 && $parts[0] === $username) {
return trim($parts[2]);
}
}
}
return null;
}
// Determine if TOTP is enabled by checking users.txt.
$totp_enabled = false;
$username = $_SESSION['username'] ?? '';
if ($username) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(":", trim($line));
// Assuming first field is username and fourth (if exists) is the TOTP secret.
if ($parts[0] === $username) {
if (isset($parts[3]) && trim($parts[3]) !== "") {
$totp_enabled = true;
}
break;
}
}
}
// Use getUserRole() to determine admin status.
// We cast the role to an integer so that "1" (string) is treated as true.
$userRole = getUserRole($username);
$isAdmin = ((int)$userRole === 1);
// Build and return the JSON response.
$response = [
"authenticated" => true,
"isAdmin" => $isAdmin,
"totp_enabled" => $totp_enabled,
"username" => $username,
"folderOnly" => isset($_SESSION["folderOnly"]) ? $_SESSION["folderOnly"] : false
];
echo json_encode($response);
?>

View File

@@ -5,7 +5,8 @@
"require": { "require": {
"jumbojett/openid-connect-php": "^1.0.0", "jumbojett/openid-connect-php": "^1.0.0",
"phpseclib/phpseclib": "~3.0.7", "phpseclib/phpseclib": "~3.0.7",
"robthree/twofactorauth": "^1.7", "robthree/twofactorauth": "^3.0",
"endroid/qr-code": "^4.0" "endroid/qr-code": "^5.0",
"sabre/dav": "^4.4"
} }
} }

573
composer.lock generated
View File

@@ -4,32 +4,32 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c9857f23364f2280ef4b71cdc72d3f78", "content-hash": "3a9b8d9fcfdaaa865ba03eab392e88fd",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
"version": "2.0.8", "version": "v3.0.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git", "url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"dasprid/enum": "^1.0.3", "dasprid/enum": "^1.0.3",
"ext-iconv": "*", "ext-iconv": "*",
"php": "^7.1 || ^8.0" "php": "^8.1"
}, },
"require-dev": { "require-dev": {
"phly/keep-a-changelog": "^2.1", "phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^7 | ^8 | ^9", "phpunit/phpunit": "^10.5.11 || 11.0.4",
"spatie/phpunit-snapshot-assertions": "^4.2.9", "spatie/phpunit-snapshot-assertions": "^5.1.5",
"squizlabs/php_codesniffer": "^3.4" "squizlabs/php_codesniffer": "^3.9"
}, },
"suggest": { "suggest": {
"ext-imagick": "to generate QR code images" "ext-imagick": "to generate QR code images"
@@ -56,9 +56,9 @@
"homepage": "https://github.com/Bacon/BaconQrCode", "homepage": "https://github.com/Bacon/BaconQrCode",
"support": { "support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues", "issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1"
}, },
"time": "2022-12-07T17:46:57+00:00" "time": "2024-10-01T13:55:55+00:00"
}, },
{ {
"name": "dasprid/enum", "name": "dasprid/enum",
@@ -112,29 +112,26 @@
}, },
{ {
"name": "endroid/qr-code", "name": "endroid/qr-code",
"version": "4.8.5", "version": "5.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/endroid/qr-code.git", "url": "https://github.com/endroid/qr-code.git",
"reference": "0db25b506a8411a5e1644ebaa67123a6eb7b6a77" "reference": "393fec6c4cbdc1bd65570ac9d245704428010122"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/endroid/qr-code/zipball/0db25b506a8411a5e1644ebaa67123a6eb7b6a77", "url": "https://api.github.com/repos/endroid/qr-code/zipball/393fec6c4cbdc1bd65570ac9d245704428010122",
"reference": "0db25b506a8411a5e1644ebaa67123a6eb7b6a77", "reference": "393fec6c4cbdc1bd65570ac9d245704428010122",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"bacon/bacon-qr-code": "^2.0.5", "bacon/bacon-qr-code": "^3.0",
"php": "^8.1" "php": "^8.1"
}, },
"conflict": {
"khanamiryan/qrcode-detector-decoder": "^1.0.6"
},
"require-dev": { "require-dev": {
"endroid/quality": "dev-master", "endroid/quality": "dev-main",
"ext-gd": "*", "ext-gd": "*",
"khanamiryan/qrcode-detector-decoder": "^1.0.4||^2.0.2", "khanamiryan/qrcode-detector-decoder": "^2.0.2",
"setasign/fpdf": "^1.8.2" "setasign/fpdf": "^1.8.2"
}, },
"suggest": { "suggest": {
@@ -146,7 +143,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "4.x-dev" "dev-main": "5.x-dev"
} }
}, },
"autoload": { "autoload": {
@@ -175,7 +172,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/endroid/qr-code/issues", "issues": "https://github.com/endroid/qr-code/issues",
"source": "https://github.com/endroid/qr-code/tree/4.8.5" "source": "https://github.com/endroid/qr-code/tree/5.1.0"
}, },
"funding": [ "funding": [
{ {
@@ -183,7 +180,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2023-09-29T14:03:20+00:00" "time": "2024-09-08T08:52:55+00:00"
}, },
{ {
"name": "jumbojett/openid-connect-php", "name": "jumbojett/openid-connect-php",
@@ -455,25 +452,76 @@
"time": "2024-12-14T21:12:59+00:00" "time": "2024-12-14T21:12:59+00:00"
}, },
{ {
"name": "robthree/twofactorauth", "name": "psr/log",
"version": "1.8.2", "version": "3.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/RobThree/TwoFactorAuth.git", "url": "https://github.com/php-fig/log.git",
"reference": "65681de5a324eae05140ac58b08648a60212afc0" "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/65681de5a324eae05140ac58b08648a60212afc0", "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "65681de5a324eae05140ac58b08648a60212afc0", "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.6.0" "php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+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": { "require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2", "friendsofphp/php-cs-fixer": "^3.13",
"phpunit/phpunit": "@stable" "phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^9"
}, },
"suggest": { "suggest": {
"bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider", "bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider",
@@ -494,6 +542,16 @@
"name": "Rob Janssen", "name": "Rob Janssen",
"homepage": "http://robiii.me", "homepage": "http://robiii.me",
"role": "Developer" "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", "description": "Two Factor Authentication",
@@ -522,7 +580,452 @@
"type": "github" "type": "github"
} }
], ],
"time": "2022-03-22T16:11:07+00:00" "time": "2024-10-24T15:14:25+00:00"
},
{
"name": "sabre/dav",
"version": "4.7.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/dav.git",
"reference": "074373bcd689a30bcf5aaa6bbb20a3395964ce7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/dav/zipball/074373bcd689a30bcf5aaa6bbb20a3395964ce7a",
"reference": "074373bcd689a30bcf5aaa6bbb20a3395964ce7a",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-dom": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"ext-spl": "*",
"lib-libxml": ">=2.7.0",
"php": "^7.1.0 || ^8.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"sabre/event": "^5.0",
"sabre/http": "^5.0.5",
"sabre/uri": "^2.0",
"sabre/vobject": "^4.2.1",
"sabre/xml": "^2.0.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.19",
"monolog/monolog": "^1.27 || ^2.0",
"phpstan/phpstan": "^0.12 || ^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"ext-curl": "*",
"ext-imap": "*",
"ext-pdo": "*"
},
"bin": [
"bin/sabredav",
"bin/naturalselection"
],
"type": "library",
"autoload": {
"psr-4": {
"Sabre\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "WebDAV Framework for PHP",
"homepage": "http://sabre.io/",
"keywords": [
"CalDAV",
"CardDAV",
"WebDAV",
"framework",
"iCalendar"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/dav/issues",
"source": "https://github.com/fruux/sabre-dav"
},
"time": "2024-10-29T11:46:02+00:00"
},
{
"name": "sabre/event",
"version": "5.1.7",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/event.git",
"reference": "86d57e305c272898ba3c28e9bd3d65d5464587c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/event/zipball/86d57e305c272898ba3c28e9bd3d65d5464587c2",
"reference": "86d57e305c272898ba3c28e9bd3d65d5464587c2",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||^3.63",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/coroutine.php",
"lib/Loop/functions.php",
"lib/Promise/functions.php"
],
"psr-4": {
"Sabre\\Event\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "sabre/event is a library for lightweight event-based programming",
"homepage": "http://sabre.io/event/",
"keywords": [
"EventEmitter",
"async",
"coroutine",
"eventloop",
"events",
"hooks",
"plugin",
"promise",
"reactor",
"signal"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/event/issues",
"source": "https://github.com/fruux/sabre-event"
},
"time": "2024-08-27T11:23:05+00:00"
},
{
"name": "sabre/http",
"version": "5.1.12",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/http.git",
"reference": "dedff73f3995578bc942fa4c8484190cac14f139"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/http/zipball/dedff73f3995578bc942fa4c8484190cac14f139",
"reference": "dedff73f3995578bc942fa4c8484190cac14f139",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-curl": "*",
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/event": ">=4.0 <6.0",
"sabre/uri": "^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||^3.63",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"ext-curl": " to make http requests with the Client class"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\HTTP\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "The sabre/http library provides utilities for dealing with http requests and responses. ",
"homepage": "https://github.com/fruux/sabre-http",
"keywords": [
"http"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/http/issues",
"source": "https://github.com/fruux/sabre-http"
},
"time": "2024-08-27T16:07:41+00:00"
},
{
"name": "sabre/uri",
"version": "2.3.4",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "b76524c22de90d80ca73143680a8e77b1266c291"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/b76524c22de90d80ca73143680a8e77b1266c291",
"reference": "b76524c22de90d80ca73143680a8e77b1266c291",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.63",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2024-08-27T12:18:16+00:00"
},
{
"name": "sabre/vobject",
"version": "4.5.7",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "ff22611a53782e90c97be0d0bc4a5f98a5c0a12c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/ff22611a53782e90c97be0d0bc4a5f98a5c0a12c",
"reference": "ff22611a53782e90c97be0d0bc4a5f98a5c0a12c",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2025-04-17T09:22:48+00:00"
},
{
"name": "sabre/xml",
"version": "2.2.11",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.1 || ^8.0",
"sabre/uri": ">=1.0,<3.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||3.63.2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2024-09-06T07:37:46+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],

View File

@@ -1,145 +0,0 @@
<?php
// config.php
// Define constants.
define('UPLOAD_DIR', '/var/www/uploads/');
define('USERS_DIR', '/var/www/users/');
define('USERS_FILE', 'users.txt');
define('META_DIR', '/var/www/metadata/');
define('META_FILE', 'file_metadata.json');
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');
date_default_timezone_set(TIMEZONE);
/**
* Encrypts data using AES-256-CBC.
*
* @param string $data The plaintext.
* @param string $encryptionKey The encryption key.
* @return string Base64-encoded string containing IV and ciphertext.
*/
function encryptData($data, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$ivlen = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
$ciphertext = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $ciphertext);
}
/**
* Decrypts data encrypted with AES-256-CBC.
*
* @param string $encryptedData Base64-encoded data containing IV and ciphertext.
* @param string $encryptionKey The encryption key.
* @return string|false The decrypted plaintext or false on failure.
*/
function decryptData($encryptedData, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$data = base64_decode($encryptedData);
$ivlen = openssl_cipher_iv_length($cipher);
$iv = substr($data, 0, $ivlen);
$ciphertext = substr($data, $ivlen);
return openssl_decrypt($ciphertext, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
}
// 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.');
}
function loadUserPermissions($username)
{
global $encryptionKey;
$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);
}
if (is_array($permissions) && array_key_exists($username, $permissions)) {
$result = $permissions[$username];
return !empty($result) ? $result : false;
}
}
// Removed error_log() to prevent flooding logs when file is not found.
return false; // Return false if no permissions found.
}
// Determine whether HTTPS is used.
$envSecure = getenv('SECURE');
if ($envSecure !== false) {
$secure = filter_var($envSecure, FILTER_VALIDATE_BOOLEAN);
} else {
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
}
$cookieParams = [
'lifetime' => 7200,
'path' => '/',
'domain' => '', // Set your domain as needed.
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax'
];
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params($cookieParams);
ini_set('session.gc_maxlifetime', 7200);
session_start();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Auto-login via persistent token.
if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token'])) {
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
$persistentTokens = [];
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
$persistentTokens = json_decode($decryptedContent, true);
if (!is_array($persistentTokens)) {
$persistentTokens = [];
}
}
if (isset($persistentTokens[$_COOKIE['remember_me_token']])) {
$tokenData = $persistentTokens[$_COOKIE['remember_me_token']];
if ($tokenData['expiry'] >= time()) {
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $tokenData["username"];
// IMPORTANT: Set the folderOnly flag here for auto-login.
$_SESSION["folderOnly"] = loadUserPermissions($tokenData["username"]);
} else {
unset($persistentTokens[$_COOKIE['remember_me_token']]);
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
}
}
}
define('BASE_URL', 'http://yourwebsite/uploads/');
if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShareUrl = isset($_SERVER['HTTP_HOST'])
? "http://" . $_SERVER['HTTP_HOST'] . "/share.php"
: "http://localhost/share.php";
} else {
$defaultShareUrl = rtrim(BASE_URL, '/') . "/share.php";
}
define('SHARE_URL', getenv('SHARE_URL') ? getenv('SHARE_URL') : $defaultShareUrl);

152
config/config.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
// config.php
// Prevent caching
header("Cache-Control: no-cache, must-revalidate");
header("Pragma: no-cache");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Expires: 0");
// Security headers
header('X-Content-Type-Options: nosniff');
header("X-Frame-Options: SAMEORIGIN");
header("Referrer-Policy: no-referrer-when-downgrade");
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
header("X-XSS-Protection: 1; mode=block");
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
}
// Define constants
define('PROJECT_ROOT', dirname(__DIR__));
define('UPLOAD_DIR', '/var/www/uploads/');
define('USERS_DIR', '/var/www/users/');
define('USERS_FILE', 'users.txt');
define('META_DIR', '/var/www/metadata/');
define('META_FILE', 'file_metadata.json');
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);
// Encryption helpers
function encryptData($data, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$ivlen = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
$ct = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $ct);
}
function decryptData($encryptedData, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$data = base64_decode($encryptedData);
$ivlen = openssl_cipher_iv_length($cipher);
$iv = substr($data, 0, $ivlen);
$ct = substr($data, $ivlen);
return openssl_decrypt($ct, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
}
// Load encryption key
$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;
}
// Helper to load JSON permissions (with optional decryption)
function loadUserPermissions($username)
{
global $encryptionKey;
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
$decrypted = decryptData($content, $encryptionKey);
$json = ($decrypted !== false) ? $decrypted : $content;
$perms = json_decode($json, true);
if (is_array($perms) && isset($perms[$username])) {
return !empty($perms[$username]) ? $perms[$username] : false;
}
}
return false;
}
// Determine HTTPS usage
$envSecure = getenv('SECURE');
$secure = ($envSecure !== false)
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// Choose session lifetime based on "remember me" cookie
$defaultSession = 7200; // 2 hours
$persistentDays = 30 * 24 * 60 * 60; // 30 days
$sessionLifetime = isset($_COOKIE['remember_me_token'])
? $persistentDays
: $defaultSession;
// Configure PHP session cookie and GC
session_set_cookie_params([
'lifetime' => $sessionLifetime,
'path' => '/',
'domain' => '', // adjust if you need a specific domain
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax'
]);
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Autologin via persistent token
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
$tokFile = USERS_DIR . 'persistent_tokens.json';
$tokens = [];
if (file_exists($tokFile)) {
$enc = file_get_contents($tokFile);
$dec = decryptData($enc, $encryptionKey);
$tokens = json_decode($dec, true) ?: [];
}
$token = $_COOKIE['remember_me_token'];
if (!empty($tokens[$token])) {
$data = $tokens[$token];
if ($data['expiry'] >= time()) {
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $data["username"];
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
$_SESSION["isAdmin"] = !empty($data["isAdmin"]);
} else {
// expired — clean up
unset($tokens[$token]);
file_put_contents($tokFile, encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey), LOCK_EX);
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
}
}
}
// Share URL fallback
define('BASE_URL', 'http://yourwebsite/uploads/');
if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShare = isset($_SERVER['HTTP_HOST'])
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
: "http://localhost/api/file/share.php";
} else {
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
}
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);

View File

@@ -1,153 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// --- CSRF Protection ---
$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;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
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 copy files."]);
exit();
}
}
$data = json_decode(file_get_contents("php://input"), true);
if (
!$data ||
!isset($data['source']) ||
!isset($data['destination']) ||
!isset($data['files'])
) {
echo json_encode(["error" => "Invalid request"]);
exit;
}
$sourceFolder = trim($data['source']);
$destinationFolder = trim($data['destination']);
$files = $data['files'];
// Validate folder names: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
$folderPattern = '/^[A-Za-z0-9_\- \/]+$/';
if ($sourceFolder !== 'root' && !preg_match($folderPattern, $sourceFolder)) {
echo json_encode(["error" => "Invalid source folder name."]);
exit;
}
if ($destinationFolder !== 'root' && !preg_match($folderPattern, $destinationFolder)) {
echo json_encode(["error" => "Invalid destination folder name."]);
exit;
}
// Trim any leading/trailing slashes and spaces.
$sourceFolder = trim($sourceFolder, "/\\ ");
$destinationFolder = trim($destinationFolder, "/\\ ");
// Build the source and destination directories.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$sourceDir = ($sourceFolder === 'root')
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . $sourceFolder . DIRECTORY_SEPARATOR;
$destDir = ($destinationFolder === 'root')
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . $destinationFolder . DIRECTORY_SEPARATOR;
// Helper: Generate the metadata file path for a given folder.
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
// Helper: Generate a unique file name if a file with the same name exists.
function getUniqueFileName($destDir, $fileName) {
$fullPath = $destDir . $fileName;
clearstatcache(true, $fullPath);
if (!file_exists($fullPath)) {
return $fileName;
}
$basename = pathinfo($fileName, PATHINFO_FILENAME);
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
$counter = 1;
do {
$newName = $basename . " (" . $counter . ")" . ($extension ? "." . $extension : "");
$newFullPath = $destDir . $newName;
clearstatcache(true, $newFullPath);
$counter++;
} while (file_exists($destDir . $newName));
return $newName;
}
// Load source and destination metadata.
$srcMetaFile = getMetadataFilePath($sourceFolder);
$destMetaFile = getMetadataFilePath($destinationFolder);
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
$errors = [];
// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces.
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
foreach ($files as $fileName) {
// Save the original name for metadata lookup.
$originalName = basename(trim($fileName));
$basename = $originalName;
if (!preg_match($safeFileNamePattern, $basename)) {
$errors[] = "$basename has an invalid name.";
continue;
}
$srcPath = $sourceDir . $originalName;
$destPath = $destDir . $basename;
clearstatcache();
if (!file_exists($srcPath)) {
$errors[] = "$originalName does not exist in source.";
continue;
}
if (file_exists($destPath)) {
$uniqueName = getUniqueFileName($destDir, $basename);
$basename = $uniqueName; // update the file name for metadata and destination path
$destPath = $destDir . $uniqueName;
}
if (!copy($srcPath, $destPath)) {
$errors[] = "Failed to copy $basename";
continue;
}
// Update destination metadata: if there's metadata for the original file in source, add it under the new name.
if (isset($srcMetadata[$originalName])) {
$destMetadata[$basename] = $srcMetadata[$originalName];
}
}
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
$errors[] = "Failed to update destination metadata.";
}
if (empty($errors)) {
echo json_encode(["success" => "Files copied successfully"]);
} else {
echo json_encode(["error" => implode("; ", $errors)]);
}
?>

View File

@@ -1,96 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// Ensure the request is a POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Invalid request method.']);
exit;
}
$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(['success' => false, 'error' => 'Invalid CSRF token.']);
http_response_code(403);
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 folders."]);
exit();
}
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folderName'])) {
echo json_encode(['success' => false, 'error' => 'Folder name not provided.']);
exit;
}
$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)) {
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)) {
echo json_encode(['success' => false, 'error' => 'Invalid parent folder name.']);
exit;
}
// Build the full folder path.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
if ($parent && strtolower($parent) !== "root") {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $parent . "/" . $folderName;
} else {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $folderName;
}
// Check if the folder already exists.
if (file_exists($fullPath)) {
echo json_encode(['success' => false, 'error' => 'Folder already exists.']);
exit;
}
// Attempt to create the folder.
if (mkdir($fullPath, 0755, true)) {
// --- Create an empty metadata file for the new folder ---
// 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".
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
$metadataFile = getMetadataFilePath($relativePath);
// Create an empty associative array (i.e. empty metadata) and write to the metadata file.
file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT));
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Failed to create folder.']);
}
?>

View File

@@ -1,65 +0,0 @@
<?php
// createShareLink.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;
}
$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)) {
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
// Generate a secure token.
$token = bin2hex(random_bytes(16)); // 32 hex characters.
// Calculate expiration (Unix timestamp).
$expires = time() + ($expirationMinutes * 60);
// Hash password if provided.
$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : "";
// File to store share links.
$shareFile = META_DIR . "share_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 ($link["expires"] < $currentTime) {
unset($shareLinks[$key]);
}
}
// Add record.
$shareLinks[$token] = [
"folder" => $folder,
"file" => $file,
"expires" => $expires,
"password" => $hashedPassword
];
// Save the share links.
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT))) {
echo json_encode(["token" => $token, "expires" => $expires]);
} else {
echo json_encode(["error" => "Could not save share link."]);
}
?>

53
custom-php.ini Normal file
View File

@@ -0,0 +1,53 @@
; custom-php.ini
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; OPcache Settings
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
opcache.enable=1
opcache.enable_cli=0
; Allocate 128MB of memory for opcode caching
opcache.memory_consumption=128
; Increase the maximum number of accelerated files (adjust if you have a large codebase)
opcache.max_accelerated_files=4000
; Refresh file timestamp every 60 seconds to avoid too many disk reads
opcache.revalidate_freq=60
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Memory and Execution Time Limits
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Increase memory limit to 512M for large file processing or image processing operations
memory_limit=512M
; Set execution time limits to accommodate long-running uploads/processes
max_execution_time=300
max_input_time=300
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Realpath Cache Settings
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
realpath_cache_size=4096k
realpath_cache_ttl=600
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; File Upload Settings
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Allow a maximum of 20 files per request
max_file_uploads=20
; Ensure the temporary directory is set (should exist and be writable)
upload_tmp_dir=/tmp
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Session Configuration (if applicable)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
session.gc_maxlifetime=1440
session.gc_probability=1
session.gc_divisor=100
session.save_path = "/var/www/sessions"
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Error Handling / Logging
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Do not display errors publicly in production
display_errors=Off
; Log errors to a dedicated file
log_errors=On
error_log=/var/log/php8.3-error.log

View File

@@ -1,161 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// --- CSRF Protection ---
$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;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// Define $username first.
$username = $_SESSION['username'] ?? '';
// Now load the user's permissions.
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only.
if ($username) {
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
exit();
}
}
// --- Setup Trash Folder & Metadata ---
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (!file_exists($trashDir)) {
mkdir($trashDir, 0755, true);
}
$trashMetadataFile = $trashDir . "trash.json";
$trashData = [];
if (file_exists($trashMetadataFile)) {
$json = file_get_contents($trashMetadataFile);
$trashData = json_decode($json, true);
if (!is_array($trashData)) {
$trashData = [];
}
}
// Helper: Generate the metadata file path for a given folder.
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
// Read request body
$data = json_decode(file_get_contents("php://input"), true);
// Validate request
if (!isset($data['files']) || !is_array($data['files'])) {
echo json_encode(["error" => "No file names provided"]);
exit;
}
// Determine folder default to 'root'
$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)) {
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
$folder = trim($folder, "/\\ ");
// Build the upload directory.
if ($folder !== 'root') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
} else {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
}
// Load folder metadata (if exists) to retrieve uploader and upload date.
$metadataFile = getMetadataFilePath($folder);
$folderMetadata = [];
if (file_exists($metadataFile)) {
$folderMetadata = json_decode(file_get_contents($metadataFile), true);
if (!is_array($folderMetadata)) {
$folderMetadata = [];
}
}
$movedFiles = [];
$errors = [];
// Define a safe file name pattern: allow letters, numbers, underscores, dashes, dots, and spaces.
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
foreach ($data['files'] as $fileName) {
$basename = basename(trim($fileName));
// Validate the file name.
if (!preg_match($safeFileNamePattern, $basename)) {
$errors[] = "$basename has an invalid name.";
continue;
}
$filePath = $uploadDir . $basename;
if (file_exists($filePath)) {
// Append a timestamp to the file name in trash to avoid collisions.
$timestamp = time();
$trashFileName = $basename . "_" . $timestamp;
if (rename($filePath, $trashDir . $trashFileName)) {
$movedFiles[] = $basename;
// Record trash metadata for possible restoration.
$trashData[] = [
'type' => 'file',
'originalFolder' => $uploadDir, // You could also store a relative path here.
'originalName' => $basename,
'trashName' => $trashFileName,
'trashedAt' => $timestamp,
// Enrich trash record with uploader and upload date from folder metadata (if available)
'uploaded' => isset($folderMetadata[$basename]['uploaded']) ? $folderMetadata[$basename]['uploaded'] : "Unknown",
'uploader' => isset($folderMetadata[$basename]['uploader']) ? $folderMetadata[$basename]['uploader'] : "Unknown",
// NEW: Record the username of the user who deleted the file.
'deletedBy' => isset($_SESSION['username']) ? $_SESSION['username'] : "Unknown"
];
} else {
$errors[] = "Failed to move $basename to Trash.";
}
} else {
// Consider file already deleted.
$movedFiles[] = $basename;
}
}
// Write back the updated trash metadata.
file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT));
// Update folder-specific metadata file by removing deleted files.
if (file_exists($metadataFile)) {
$metadata = json_decode(file_get_contents($metadataFile), true);
if (is_array($metadata)) {
foreach ($movedFiles as $delFile) {
if (isset($metadata[$delFile])) {
unset($metadata[$delFile]);
}
}
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT));
}
}
if (empty($errors)) {
echo json_encode(["success" => "Files moved to Trash: " . implode(", ", $movedFiles)]);
} else {
echo json_encode(["error" => implode("; ", $errors) . ". Files moved to Trash: " . implode(", ", $movedFiles)]);
}
?>

View File

@@ -1,99 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// Ensure the request is a POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Invalid request method.']);
exit;
}
$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(['success' => false, 'error' => 'Invalid CSRF token.']);
http_response_code(403);
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 delete folders."]);
exit();
}
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folder'])) {
echo json_encode(['success' => false, 'error' => 'Folder name not provided.']);
exit;
}
$folderName = trim($input['folder']);
// Prevent deletion of root.
if ($folderName === 'root') {
echo json_encode(['success' => false, 'error' => 'Cannot delete root folder.']);
exit;
}
// Allow letters, numbers, underscores, dashes, spaces, and forward slashes.
if (!preg_match('/^[A-Za-z0-9_\- \/]+$/', $folderName)) {
echo json_encode(['success' => false, 'error' => 'Invalid folder name.']);
exit;
}
// Build the folder path (supports subfolder paths like "FolderTest/FolderTestSub")
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folderName;
// Check if the folder exists and is a directory
if (!file_exists($folderPath) || !is_dir($folderPath)) {
echo json_encode(['success' => false, 'error' => 'Folder does not exist.']);
exit;
}
// Prevent deletion if the folder is not empty
if (count(scandir($folderPath)) > 2) {
echo json_encode(['success' => false, 'error' => 'Folder is not empty.']);
exit;
}
/**
* Helper: Generate the metadata file path for a given folder.
* For "root", returns "root_metadata.json". Otherwise, it 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 === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
// Attempt to delete the folder.
if (rmdir($folderPath)) {
// Remove corresponding metadata file if it exists.
$metadataFile = getMetadataFilePath($folderName);
if (file_exists($metadataFile)) {
unlink($metadataFile);
}
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Failed to delete folder.']);
}
?>

View File

@@ -1,104 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// --- CSRF Protection ---
$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;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// --- Setup Trash Folder & Metadata ---
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (!file_exists($trashDir)) {
mkdir($trashDir, 0755, true);
}
$trashMetadataFile = $trashDir . "trash.json";
// Load trash metadata into an associative array keyed by trashName.
$trashData = [];
if (file_exists($trashMetadataFile)) {
$json = file_get_contents($trashMetadataFile);
$tempData = json_decode($json, true);
if (is_array($tempData)) {
foreach ($tempData as $item) {
if (isset($item['trashName'])) {
$trashData[$item['trashName']] = $item;
}
}
}
}
// Read request body.
$data = json_decode(file_get_contents("php://input"), true);
if (!$data) {
echo json_encode(["error" => "Invalid input"]);
exit;
}
// Determine deletion mode: if "deleteAll" is true, delete all trash items; otherwise, use provided "files" array.
$filesToDelete = [];
if (isset($data['deleteAll']) && $data['deleteAll'] === true) {
$filesToDelete = array_keys($trashData);
} elseif (isset($data['files']) && is_array($data['files'])) {
$filesToDelete = $data['files'];
} else {
echo json_encode(["error" => "No trash file identifiers provided"]);
exit;
}
$deletedFiles = [];
$errors = [];
// Define a safe file name pattern.
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
foreach ($filesToDelete as $trashName) {
$trashName = trim($trashName);
if (!preg_match($safeFileNamePattern, $trashName)) {
$errors[] = "$trashName has an invalid format.";
continue;
}
if (!isset($trashData[$trashName])) {
$errors[] = "Trash item $trashName not found.";
continue;
}
$filePath = $trashDir . $trashName;
if (file_exists($filePath)) {
if (unlink($filePath)) {
$deletedFiles[] = $trashName;
unset($trashData[$trashName]);
} else {
$errors[] = "Failed to delete $trashName.";
}
} else {
// If the file doesn't exist, remove its metadata entry.
unset($trashData[$trashName]);
$deletedFiles[] = $trashName;
}
}
// Write the updated trash metadata back (as an indexed array).
file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT));
if (empty($errors)) {
echo json_encode(["success" => "Trash items deleted: " . implode(", ", $deletedFiles)]);
} else {
echo json_encode(["error" => implode("; ", $errors) . ". Trash items deleted: " . implode(", ", $deletedFiles)]);
}
exit;
?>

View File

@@ -1,89 +0,0 @@
<?php
require_once 'config.php';
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Get file parameters from the GET request.
$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)) {
http_response_code(400);
echo json_encode(["error" => "Invalid file name."]);
exit;
}
// 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;
}
// 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;
}
}
// 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($realFilePath);
header("Content-Type: " . $mimeType);
// For images, serve inline; for other types, force download.
$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) . '"');
}
header('Content-Length: ' . filesize($realFilePath));
// Disable caching.
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($realFilePath);
exit;
?>

View File

@@ -1,133 +0,0 @@
<?php
require_once 'config.php';
// --- CSRF Protection ---
$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;
}
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Read and decode the JSON input.
$rawData = file_get_contents("php://input");
$data = json_decode($rawData, true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Invalid input."]);
exit;
}
$folder = $data['folder'];
$files = $data['files'];
// Validate folder name to allow subfolders.
// "root" is allowed; otherwise, split by "/" and validate each segment.
if ($folder !== "root") {
$parts = explode('/', $folder);
foreach ($parts as $part) {
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
}
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
} else {
$relativePath = "";
}
// Use the absolute UPLOAD_DIR from config.php.
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(["error" => "Uploads directory not configured correctly."]);
exit;
}
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
$folderPathReal = realpath($folderPath);
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(["error" => "Folder not found."]);
exit;
}
if (empty($files)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "No files specified."]);
exit;
}
foreach ($files as $fileName) {
if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $fileName)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Invalid file name: " . $fileName]);
exit;
}
}
// Build an array of files to include in the ZIP.
$filesToZip = [];
foreach ($files as $fileName) {
$filePath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
if (file_exists($filePath)) {
$filesToZip[] = $filePath;
}
}
if (empty($filesToZip)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "No valid files found to zip."]);
exit;
}
// Create a temporary file for the ZIP archive.
$tempZip = tempnam(sys_get_temp_dir(), 'zip');
unlink($tempZip); // Remove the temporary file so ZipArchive can create a new one.
$tempZip .= '.zip';
$zip = new ZipArchive();
if ($zip->open($tempZip, ZipArchive::CREATE) !== TRUE) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(["error" => "Could not create zip archive."]);
exit;
}
// Add each file to the archive using its base name.
foreach ($filesToZip as $filePath) {
$zip->addFile($filePath, basename($filePath));
}
$zip->close();
// Send headers to force download and disable caching.
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="files.zip"');
header('Content-Length: ' . filesize($tempZip));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
// Output the file and delete it afterward.
readfile($tempZip);
unlink($tempZip);
exit;
?>

View File

@@ -1,165 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// --- CSRF Protection ---
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
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 extract zip files"]);
exit();
}
}
// Read and decode the JSON input.
$rawData = file_get_contents("php://input");
$data = json_decode($rawData, true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
http_response_code(400);
echo json_encode(["error" => "Invalid input."]);
exit;
}
$folder = $data['folder'];
$files = $data['files'];
if (empty($files)) {
http_response_code(400);
echo json_encode(["error" => "No files specified."]);
exit;
}
// Validate folder name (allow "root" or valid subfolder names).
if ($folder !== "root") {
$parts = explode('/', $folder);
foreach ($parts as $part) {
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
}
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
} else {
$relativePath = "";
}
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
http_response_code(500);
echo json_encode(["error" => "Uploads directory not configured correctly."]);
exit;
}
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
$folderPathReal = realpath($folderPath);
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
http_response_code(404);
echo json_encode(["error" => "Folder not found."]);
exit;
}
// ---------- Metadata Setup ----------
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
$srcMetaFile = getMetadataFilePath($folder);
$destMetaFile = getMetadataFilePath($folder);
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
$errors = [];
$allSuccess = true;
$extractedFiles = array(); // Array to collect names of extracted files
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
// ---------- Process Each File ----------
foreach ($files as $zipFileName) {
$originalName = basename(trim($zipFileName));
// Process only .zip files.
if (strtolower(substr($originalName, -4)) !== '.zip') {
continue;
}
if (!preg_match($safeFileNamePattern, $originalName)) {
$errors[] = "$originalName has an invalid name.";
$allSuccess = false;
continue;
}
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $originalName;
if (!file_exists($zipFilePath)) {
$errors[] = "$originalName does not exist in folder.";
$allSuccess = false;
continue;
}
$zip = new ZipArchive();
if ($zip->open($zipFilePath) !== TRUE) {
$errors[] = "Could not open $originalName as a zip file.";
$allSuccess = false;
continue;
}
// Attempt extraction.
if (!$zip->extractTo($folderPathReal)) {
$errors[] = "Failed to extract $originalName.";
$allSuccess = false;
} else {
// Collect extracted file names from this zip.
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
$extractedFileName = basename($entryName);
if ($extractedFileName) {
$extractedFiles[] = $extractedFileName;
}
}
// Update metadata for each extracted file if the zip file has metadata.
if (isset($srcMetadata[$originalName])) {
$zipMeta = $srcMetadata[$originalName];
// Iterate through all entries in the zip.
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
$extractedFileName = basename($entryName);
if ($extractedFileName) {
$destMetadata[$extractedFileName] = $zipMeta;
}
}
}
}
$zip->close();
}
// Write updated metadata back to the destination metadata file.
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
$errors[] = "Failed to update metadata.";
$allSuccess = false;
}
if ($allSuccess) {
echo json_encode(["success" => true, "extractedFiles" => $extractedFiles]);
} else {
echo json_encode(["success" => false, "error" => implode(" ", $errors)]);
}
exit;
?>

View File

@@ -1,36 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
$configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
if ($decryptedContent === false) {
http_response_code(500);
echo json_encode(['error' => 'Failed to decrypt configuration.']);
exit;
}
// Decode the configuration and ensure globalOtpauthUrl is set
$config = json_decode($decryptedContent, true);
if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = "";
}
echo json_encode($config);
} else {
echo json_encode([
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => 'YOUR_CLIENT_ID',
'clientSecret' => 'YOUR_CLIENT_SECRET',
'redirectUri' => 'https://yourdomain.com/auth.php?oidc=callback'
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => false
],
'globalOtpauthUrl' => ""
]);
}
?>

View File

@@ -1,106 +0,0 @@
<?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
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
$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)) {
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
// Determine the directory based on the folder parameter.
if ($folder !== 'root') {
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
} else {
$directory = UPLOAD_DIR;
}
/**
* 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 === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
$metadataFile = getMetadataFilePath($folder);
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_dir($directory)) {
echo json_encode(["error" => "Directory not found."]);
exit;
}
$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_\-\.\(\) ]+$/';
foreach ($files as $file) {
// Skip hidden files (those that begin with a dot)
if (substr($file, 0, 1) === '.') {
continue;
}
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
// Only include files (skip directories)
if (!is_file($filePath)) continue;
// Optionally, skip files with unsafe names.
if (!preg_match($safeFileNamePattern, $file)) {
continue;
}
// 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";
$fileSizeBytes = filesize($filePath);
if ($fileSizeBytes >= 1073741824) {
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
} elseif ($fileSizeBytes >= 1048576) {
$fileSizeFormatted = sprintf("%.1f MB", $fileSizeBytes / 1048576);
} elseif ($fileSizeBytes >= 1024) {
$fileSizeFormatted = sprintf("%.1f KB", $fileSizeBytes / 1024);
} else {
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
}
$fileList[] = [
'name' => $file,
'modified' => $fileDateModified,
'uploaded' => $fileUploadedDate,
'size' => $fileSizeFormatted,
'uploader' => $fileUploader,
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
];
}
// Load global tags from createdTags.json.
$globalTagsFile = META_DIR . "createdTags.json";
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
echo json_encode(["files" => $fileList, "globalTags" => $globalTags]);
?>

View File

@@ -1,97 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
/**
* Recursively scan a directory for subfolders.
*
* @param string $dir The full path to the directory.
* @param string $relative The relative path from the base upload directory.
* @return array An array of folder paths (relative to the base).
*/
function getSubfolders($dir, $relative = '') {
$folders = [];
$items = scandir($dir);
// Allow letters, numbers, underscores, dashes, and spaces in folder names.
$safeFolderNamePattern = '/^[A-Za-z0-9_\- ]+$/';
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
if (!preg_match($safeFolderNamePattern, $item)) {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
// Build the relative path.
$folderPath = ($relative ? $relative . '/' : '') . $item;
$folders[] = $folderPath;
// Recursively get subfolders.
$subFolders = getSubfolders($path, $folderPath);
$folders = array_merge($folders, $subFolders);
}
}
return $folders;
}
/**
* Helper: Generate the metadata file path for a given folder.
* For "root", it returns "root_metadata.json"; otherwise, it 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 === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
$baseDir = rtrim(UPLOAD_DIR, '/\\');
// Build an array to hold folder information.
$folderInfoList = [];
// Include "root" as a folder.
$rootMetaFile = getMetadataFilePath('root');
$rootFileCount = 0;
if (file_exists($rootMetaFile)) {
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
}
$folderInfoList[] = [
"folder" => "root",
"fileCount" => $rootFileCount,
"metadataFile" => basename($rootMetaFile)
];
// Scan for subfolders.
$subfolders = [];
if (is_dir($baseDir)) {
$subfolders = getSubfolders($baseDir);
}
// For each subfolder, load its metadata and record file count.
foreach ($subfolders as $folder) {
$metaFile = getMetadataFilePath($folder);
$fileCount = 0;
if (file_exists($metaFile)) {
$metadata = json_decode(file_get_contents($metaFile), true);
$fileCount = is_array($metadata) ? count($metadata) : 0;
}
$folderInfoList[] = [
"folder" => $folder,
"fileCount" => $fileCount,
"metadataFile" => basename($metaFile)
];
}
echo json_encode($folderInfoList);
?>

View File

@@ -1,68 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// Define the trash directory and trash metadata file.
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
$trashMetadataFile = $trashDir . "trash.json";
// 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".
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
// Read the trash metadata.
$trashItems = [];
if (file_exists($trashMetadataFile)) {
$json = file_get_contents($trashMetadataFile);
$trashItems = json_decode($json, true);
if (!is_array($trashItems)) {
$trashItems = [];
}
}
// Enrich each trash record.
foreach ($trashItems as &$item) {
// Ensure deletedBy is set and not empty.
if (empty($item['deletedBy'])) {
$item['deletedBy'] = "Unknown";
}
// Enrich with uploader and uploaded date if not already present.
if (empty($item['uploaded']) || empty($item['uploader'])) {
if (isset($item['originalFolder']) && isset($item['originalName'])) {
$metadataFile = getMetadataFilePath($item['originalFolder']);
if (file_exists($metadataFile)) {
$metadata = json_decode(file_get_contents($metadataFile), true);
if (is_array($metadata) && isset($metadata[$item['originalName']])) {
$item['uploaded'] = !empty($metadata[$item['originalName']]['uploaded']) ? $metadata[$item['originalName']]['uploaded'] : "Unknown";
$item['uploader'] = !empty($metadata[$item['originalName']]['uploader']) ? $metadata[$item['originalName']]['uploader'] : "Unknown";
} else {
$item['uploaded'] = "Unknown";
$item['uploader'] = "Unknown";
}
} else {
$item['uploaded'] = "Unknown";
$item['uploader'] = "Unknown";
}
} else {
$item['uploaded'] = "Unknown";
$item['uploader'] = "Unknown";
}
}
}
unset($item);
echo json_encode($trashItems);
exit;
?>

View File

@@ -1,47 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$permissionsFile = USERS_DIR . "userPermissions.json";
$permissionsArray = [];
// Load permissions file if it exists.
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
// Attempt to decrypt the content.
$decryptedContent = decryptData($content, $encryptionKey);
if ($decryptedContent === false) {
// If decryption fails, assume the file is plain JSON.
$permissionsArray = json_decode($content, true);
} else {
$permissionsArray = json_decode($decryptedContent, true);
}
if (!is_array($permissionsArray)) {
$permissionsArray = [];
}
}
// If the user is an admin, return all permissions.
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
echo json_encode($permissionsArray);
exit;
}
// Otherwise, return only the current user's permissions.
$username = $_SESSION['username'] ?? '';
foreach ($permissionsArray as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0) {
echo json_encode($data);
exit;
}
}
// If no permissions are found for the current user, return an empty object.
echo json_encode(new stdClass());
?>

View File

@@ -1,31 +0,0 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$usersFile = USERS_DIR . USERS_FILE;
$users = [];
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('/^[A-Za-z0-9_\- ]+$/', $parts[0])) {
$users[] = [
"username" => $parts[0],
"role" => trim($parts[2])
];
}
}
}
}
echo json_encode($users);
?>

View File

@@ -1,409 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FileRise</title>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('logout') === '1') {
localStorage.removeItem("username");
localStorage.removeItem("userTOTPEnabled");
}
</script>
<link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content="">
<meta name="share-url" content="">
<!-- Google Fonts and Material Icons -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- Bootstrap CSS -->
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css">
<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>
<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>
</div>
<div class="header-title">
<h1>FileRise</h1>
</div>
<div class="header-right">
<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" 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>
</div>
</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>
</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">
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
<!-- Main Column -->
<div id="mainColumn" class="main-column">
<div class="container-fluid">
<!-- Login Form (unchanged) -->
<div class="row" id="loginForm">
<div class="col-12">
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required />
</div>
<div class="form-group">
<label for="loginPassword">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>
<div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox">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>
</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>
</div>
</div>
</div>
<!-- Main Operations: Upload and Folder Management -->
<div id="mainOperations">
<div class="container" style="max-width: 1400px; margin: 0 auto;">
<!-- Top Zone: Two columns (60% and 40%) -->
<div id="uploadFolderRow" class="row">
<!-- 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-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>
<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>
</div>
</div>
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto">Upload</button>
<div id="uploadProgressContainer"></div>
</form>
</div>
</div>
</div>
<!-- Right Column (40% for Folder Management Card) -->
<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 &amp; Management</span>
<button id="folderHelpBtn" class="btn btn-link" title="Folder Help"
style="padding: 0; border: none; background: none;">
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
</button>
</div>
<div class="card-body custom-folder-card-body">
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
<div id="folderTreeContainer"></div>
</div>
<div class="folder-actions mt-3">
<button id="createFolderBtn" class="btn btn-primary">Create Folder</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"
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>
</div>
</div>
</div>
<button id="renameFolderBtn" class="btn btn-secondary ml-2" 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>
<input type="text" id="newRenameFolderName" class="form-control"
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>
</div>
</div>
</div>
<button id="deleteFolderBtn" class="btn btn-danger ml-2" 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>
<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>
</div>
</div>
</div>
</div>
<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>
</ul>
</div>
</div>
</div>
</div>
</div> <!-- end uploadFolderRow -->
</div> <!-- end container -->
</div> <!-- end mainOperations -->
<!-- File List Section -->
<div id="fileListContainer" style="display: none;">
<h2 id="fileListTitle">Files in (Root)</h2>
<div id="fileListActions" class="file-list-actions">
<button id="deleteSelectedBtn" class="btn action-btn" style="display: none;">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>
<div class="modal-footer">
<button id="cancelDeleteFiles" class="btn btn-secondary">Cancel</button>
<button id="confirmDeleteFiles" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
<button id="copySelectedBtn" class="btn action-btn" style="display: none;" disabled>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>
<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>
</div>
</div>
</div>
<button id="moveSelectedBtn" class="btn action-btn" style="display: none;" disabled>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>
<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>
</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>
<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" />
<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>
</div>
</div>
</div>
</div>
<div id="fileList"></div>
</div>
</div> <!-- end container-fluid -->
</div> <!-- end mainColumn -->
</div> <!-- end main-wrapper -->
<!-- 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;">&times;</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>
</div>
</div>
<div id="addUserModal" class="modal">
<div class="modal-content">
<h3>Create New User</h3>
<label for="newUsername">Username:</label>
<input type="text" id="newUsername" class="form-control" />
<label for="addUserPassword">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>
</div>
<div class="button-container">
<button id="cancelUserBtn" class="btn btn-secondary">Cancel</button>
<button id="saveUserBtn" class="btn btn-primary">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>
<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>
</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;" />
<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>
</div>
</div>
</div>
<div id="customConfirmModal" class="modal" style="display:none;">
<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>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -1,419 +0,0 @@
// fileListView.js
import {
escapeHTML,
debounce,
buildSearchAndPaginationControls,
buildFileTableHeader,
buildFileTableRow,
buildBottomControls,
updateFileActionButtons,
showToast,
updateRowHighlight,
toggleRowSelection,
attachEnterKeyListener
} from './domUtils.js';
import { bindFileListContextMenu } from './fileMenu.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"
// -----------------------------
// 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-secondary");
const titleElem = document.getElementById("fileListTitle");
if (titleElem) {
titleElem.parentNode.insertBefore(toggleBtn, titleElem.nextSibling);
}
}
toggleBtn.textContent = window.viewMode === "gallery" ? "Switch to Table View" : "Switch to Gallery View";
toggleBtn.onclick = () => {
window.viewMode = window.viewMode === "gallery" ? "table" : "gallery";
localStorage.setItem("viewMode", window.viewMode);
loadFileList(window.currentFolder);
toggleBtn.textContent = window.viewMode === "gallery" ? "Switch to Table View" : "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;
import { openTagModal, openMultiTagModal } from './fileTags.js';
// -----------------------------
// 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 = "";
if (data.files && data.files.length > 0) {
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";
}
return file;
});
fileData = data.files;
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
} else {
fileListContainer.textContent = "No files found.";
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";
});
}
export function renderFileTable(folder) {
const fileListContainer = document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
let currentPage = window.currentPage || 1;
const filteredFiles = fileData.filter(file => {
const nameMatch = file.name.toLowerCase().includes(searchTerm);
const tagMatch = file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(searchTerm));
return nameMatch || tagMatch;
});
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("/") + "/";
const topControlsHTML = buildSearchAndPaginationControls({
currentPage,
totalPages,
searchTerm: window.currentSearchTerm || ""
});
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="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);
fileListContainer.innerHTML = topControlsHTML + headerHTML + rowsHTML + bottomControlsHTML;
createViewToggleButton();
const newSearchInput = document.getElementById("searchInput");
if (newSearchInput) {
newSearchInput.addEventListener("input", debounce(function () {
window.currentSearchTerm = newSearchInput.value;
window.currentPage = 1;
renderFileTable(folder);
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();
// Add drag-and-drop support for each table row.
document.querySelectorAll("#fileList tbody tr").forEach(row => {
row.setAttribute("draggable", "true");
import('./fileDragDrop.js').then(module => {
row.addEventListener("dragstart", module.fileDragStartHandler);
});
});
// Prevent clicks on these buttons from selecting the row
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
btn.addEventListener("click", e => e.stopPropagation());
});
// rebind context menu
bindFileListContextMenu();
}
export function renderGalleryView(folder) {
const fileListContainer = document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const filteredFiles = fileData.filter(file => {
return file.name.toLowerCase().includes(searchTerm) ||
(file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(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;">${escapeHTML(file.name)}</span>
${tagBadgesHTML}
<div class="button-wrap" style="display: flex; justify-content: center; 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">
<i class="material-icons">file_download</i>
</a>
${file.editable ? `
<button class="btn btn-sm edit-btn" onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' title="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="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="Share">
<i class="material-icons">share</i>
</button>
</div>
</div>
</div>`;
});
galleryHTML += "</div>";
fileListContainer.innerHTML = galleryHTML;
createViewToggleButton();
updateFileActionButtons();
// Bind share button clicks
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;

View File

@@ -1,31 +0,0 @@
export function sendRequest(url, method = "GET", data = null, customHeaders = {}) {
const options = {
method,
credentials: 'include',
headers: {}
};
// Merge custom headers
Object.assign(options.headers, customHeaders);
// If data is provided and is not FormData, assume JSON.
if (data && !(data instanceof FormData)) {
if (!options.headers["Content-Type"]) {
options.headers["Content-Type"] = "application/json";
}
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
options.body = data;
}
return fetch(url, options)
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP error ${response.status}: ${text}`);
});
}
const clonedResponse = response.clone();
return response.json().catch(() => clonedResponse.text());
});
}

View File

@@ -1,120 +0,0 @@
<?php
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)
{
global $usersFile;
if (!file_exists($usersFile)) {
error_log("authenticate(): users file not found");
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
list($storedUser, $storedPass, $storedRole) = explode(':', trim($line), 3);
if ($username === $storedUser && password_verify($password, $storedPass)) {
return $storedRole; // Return the user's role
}
}
error_log("authenticate(): authentication failed for '$username'");
return false;
}
// Define helper function to get a user's role from users.txt
function getUserRole($username) {
global $usersFile;
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 && $parts[0] === $username) {
return trim($parts[2]);
}
}
}
return null;
}
// Add the loadFolderPermission function here:
function loadFolderPermission($username) {
global $encryptionKey;
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
$decrypted = decryptData($content, $encryptionKey);
$permissions = $decrypted !== false ? json_decode($decrypted, true) : json_decode($content, true);
if (is_array($permissions)) {
foreach ($permissions as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0 && isset($data['folderOnly'])) {
return (bool)$data['folderOnly'];
}
}
}
}
return false;
}
// Check if the user has sent HTTP Basic auth credentials.
if (!isset($_SERVER['PHP_AUTH_USER'])) {
header('WWW-Authenticate: Basic realm="FileRise Login"');
header('HTTP/1.0 401 Unauthorized');
echo 'Authorization Required';
exit;
}
$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) {
// --- 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;
?>

View File

@@ -1,50 +0,0 @@
<?php
require_once 'config.php';
// Retrieve headers and check CSRF token.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
// 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.");
}
// Remove the remember_me token.
if (isset($_COOKIE['remember_me_token'])) {
$token = $_COOKIE['remember_me_token'];
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
$persistentTokens = json_decode($decryptedContent, true);
if (is_array($persistentTokens) && isset($persistentTokens[$token])) {
unset($persistentTokens[$token]);
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
}
}
// 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 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?logout=1");
exit;
?>

View File

@@ -1,167 +0,0 @@
<?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);
$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;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
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 move files."]);
exit();
}
}
$data = json_decode(file_get_contents("php://input"), true);
if (
!$data ||
!isset($data['source']) ||
!isset($data['destination']) ||
!isset($data['files'])
) {
echo json_encode(["error" => "Invalid request"]);
exit;
}
$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_\- \/]+$/';
if ($sourceFolder !== 'root' && !preg_match($folderPattern, $sourceFolder)) {
echo json_encode(["error" => "Invalid source folder name."]);
exit;
}
if ($destinationFolder !== 'root' && !preg_match($folderPattern, $destinationFolder)) {
echo json_encode(["error" => "Invalid destination folder name."]);
exit;
}
// Remove any leading/trailing slashes.
$sourceFolder = trim($sourceFolder, "/\\ ");
$destinationFolder = trim($destinationFolder, "/\\ ");
// Build the source and destination directories.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$sourceDir = ($sourceFolder === 'root')
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . $sourceFolder . DIRECTORY_SEPARATOR;
$destDir = ($destinationFolder === 'root')
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . $destinationFolder . DIRECTORY_SEPARATOR;
// Ensure destination directory exists.
if (!is_dir($destDir)) {
if (!mkdir($destDir, 0775, true)) {
echo json_encode(["error" => "Could not create destination folder"]);
exit;
}
}
// Helper: Generate the metadata file path for a given folder.
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
// Helper: Generate a unique file name if a file with the same name exists.
function getUniqueFileName($destDir, $fileName) {
$fullPath = $destDir . $fileName;
clearstatcache(true, $fullPath);
if (!file_exists($fullPath)) {
return $fileName;
}
$basename = pathinfo($fileName, PATHINFO_FILENAME);
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
$counter = 1;
do {
$newName = $basename . " (" . $counter . ")" . ($extension ? "." . $extension : "");
$newFullPath = $destDir . $newName;
clearstatcache(true, $newFullPath);
$counter++;
} while (file_exists($destDir . $newName));
return $newName;
}
// Prepare metadata files.
$srcMetaFile = getMetadataFilePath($sourceFolder);
$destMetaFile = getMetadataFilePath($destinationFolder);
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
$errors = [];
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
foreach ($data['files'] as $fileName) {
// Save the original name for metadata lookup.
$originalName = basename(trim($fileName));
$basename = $originalName; // Start with the original name.
// Validate the file name.
if (!preg_match($safeFileNamePattern, $basename)) {
$errors[] = "$basename has invalid characters.";
continue;
}
$srcPath = $sourceDir . $originalName;
$destPath = $destDir . $basename;
clearstatcache();
if (!file_exists($srcPath)) {
$errors[] = "$originalName does not exist in source.";
continue;
}
// If a file with the same name exists in destination, generate a unique name.
if (file_exists($destPath)) {
$uniqueName = getUniqueFileName($destDir, $basename);
$basename = $uniqueName;
$destPath = $destDir . $uniqueName;
}
if (!rename($srcPath, $destPath)) {
$errors[] = "Failed to move $basename";
continue;
}
// Update metadata: if there is metadata for the original file, move it under the new name.
if (isset($srcMetadata[$originalName])) {
$destMetadata[$basename] = $srcMetadata[$originalName];
unset($srcMetadata[$originalName]);
}
}
if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT)) === false) {
$errors[] = "Failed to update source metadata.";
}
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
$errors[] = "Failed to update destination metadata.";
}
if (empty($errors)) {
echo json_encode(["success" => "Files moved successfully"]);
} else {
echo json_encode(["error" => implode("; ", $errors)]);
}
?>

20
public/api.html Normal file
View File

@@ -0,0 +1,20 @@
<!-- public/api.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX" crossorigin="anonymous"></script>
</head>
<body>
<redoc spec-url="openapi.json"></redoc>
<div id="redoc-container"></div>
<script>
// If the <redoc> tag didnt render, fall back to init()
if (!customElements.get('redoc')) {
Redoc.init('openapi.json', {}, document.getElementById('redoc-container'));
}
</script>
</body>
</html>

8
public/api/addUser.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
// public/api/addUser.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->addUser();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/admin/getConfig.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/adminController.php';
$adminController = new AdminController();
$adminController->getConfig();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/admin/updateConfig.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/adminController.php';
$adminController = new AdminController();
$adminController->updateConfig();

9
public/api/auth/auth.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
// public/api/auth/auth.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php';
$authController = new AuthController();
$authController->auth();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/auth/checkAuth.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php';
$authController = new AuthController();
$authController->checkAuth();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/auth/login_basic.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php';
$authController = new AuthController();
$authController->loginBasic();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/auth/logout.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php';
$authController = new AuthController();
$authController->logout();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/auth/token.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php';
$authController = new AuthController();
$authController->getToken();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/changePassword.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->changePassword();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/copyFiles.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->copyFiles();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/createShareLink.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->createShareLink();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/deleteFiles.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->deleteFiles();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/deleteTrashFiles.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->deleteTrashFiles();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/download.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->downloadFile();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/downloadZip.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->downloadZip();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/extractZip.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->extractZip();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/getFileList.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->getFileList();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/getFileTag.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->getFileTags();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/getTrashItems.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->getTrashItems();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/moveFiles.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->moveFiles();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/renameFile.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->renameFile();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/restoreFiles.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->restoreFiles();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/saveFile.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->saveFile();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/saveFileTag.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->saveFileTag();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/file/share.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
$fileController = new FileController();
$fileController->shareFile();

2
public/api/file/symlink Normal file
View File

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

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/createFolder.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->createFolder();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/createShareFolderLink.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->createShareFolderLink();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/deleteFolder.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->deleteFolder();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/downloadSharedFile.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->downloadSharedFile();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/getFolderList.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->getFolderList();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/renameFolder.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->renameFolder();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/shareFolder.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->shareFolder();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/folder/uploadToSharedFolder.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
$folderController = new FolderController();
$folderController->uploadToSharedFolder();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/getUserPermissions.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->getUserPermissions();

8
public/api/getUsers.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
// public/api/getUsers.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->getUsers(); // This will output the JSON response

View File

@@ -0,0 +1,8 @@
<?php
// public/api/removeUser.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->removeUser();

View File

@@ -0,0 +1,9 @@
<?php
// public/api/totp_disable.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->disableTOTP();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/totp_recover.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->recoverTOTP();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/totp_saveCode.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->saveTOTPRecoveryCode();

View File

@@ -0,0 +1,9 @@
<?php
// public/api/totp_setup.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->setupTOTP();

View File

@@ -0,0 +1,9 @@
<?php
// public/api/totp_verify.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->verifyTOTP();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/updateUserPanel.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->updateUserPanel();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/updateUserPermissions.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php';
$userController = new UserController();
$userController->updateUserPermissions();

View File

@@ -0,0 +1,8 @@
<?php
// public/api/upload/removeChunks.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/uploadController.php';
$uploadController = new UploadController();
$uploadController->removeChunks();

View File

@@ -0,0 +1,7 @@
<?php
// public/api/upload/upload.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/uploadController.php';
$uploadController = new UploadController();
$uploadController->handleUpload();

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -32,8 +32,8 @@ body {
@media (min-width: 1300px) { @media (min-width: 1300px) {
.container-fluid { .container-fluid {
padding-left: 40px !important; padding-left: 30px !important;
padding-right: 40px !important; padding-right: 30px !important;
} }
} }
@@ -69,7 +69,7 @@ body {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
height: 80px; height: 55px;
padding: 10px 20px; padding: 10px 20px;
background-color: #2196F3; background-color: #2196F3;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
@@ -82,28 +82,16 @@ body.dark-mode .header-container {
} }
.header-logo { .header-logo {
max-height: 70px; max-height: 50px;
width: auto; width: auto;
display: block; display: block;
} }
.header-logo svg { .header-logo svg {
height: 70px; height: 50px;
width: auto; 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 { body.dark-mode header {
background-color: #1f1f1f; background-color: #1f1f1f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
@@ -650,12 +638,15 @@ body.dark-mode .editor-header {
} }
#uploadBtn { #uploadBtn {
margin-top: 20px;
font-size: 20px; font-size: 20px;
padding: 10px 22px; padding: 10px 22px;
align-items: center; align-items: center;
} }
.card-body.d-flex.flex-column {
padding: 0.75rem !important;
}
#customChooseBtn { #customChooseBtn {
background-color: #9E9E9E; background-color: #9E9E9E;
color: #fff; color: #fff;
@@ -849,7 +840,8 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
color: white; color: white;
} }
.rename-btn .material-icons { .rename-btn .material-icons,
#renameFolderBtn .material-icons {
color: black !important; color: black !important;
} }
@@ -1580,39 +1572,6 @@ body.dark-mode .btn-secondary {
border-color: #6c757d; 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 { body.dark-mode .btn-danger {
background-color: #dc3545; background-color: #dc3545;
color: #fff; 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 { body.dark-mode .CodeMirror {
background: #1e1e1e !important; background: #1e1e1e !important;
color: #ffffff !important; color: #ffffff !important;
@@ -1767,6 +1711,20 @@ body.dark-mode .CodeMirror-matchingbracket {
border-bottom: 1px solid #ffffff !important; border-bottom: 1px solid #ffffff !important;
} }
.zoom_in,
.zoom_out,
.rotate_left,
.rotate_right {
background: rgba(80, 80, 80, 0.6) !important;
border: none !important;
color: white !important;
cursor: pointer !important;
border-radius: 4px !important;
transition: background 0.3s ease, box-shadow 0.3s ease !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3) !important;
transform: translateY(-10px);
}
.gallery-nav-btn { .gallery-nav-btn {
background: rgba(80, 80, 80, 0.6) !important; background: rgba(80, 80, 80, 0.6) !important;
border: none !important; border: none !important;
@@ -1779,21 +1737,15 @@ body.dark-mode .CodeMirror-matchingbracket {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3) !important; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3) !important;
} }
.gallery-nav-btn:hover { .gallery-nav-btn:hover,
.zoom_in:hover,
.zoom_out:hover,
.rotate_left:hover,
.rotate_right:hover {
background: rgba(80, 80, 80, 0.8) !important; background: rgba(80, 80, 80, 0.8) !important;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4) !important; box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4) !important;
} }
.gallery-nav-btn.left {
left: 10px;
right: auto;
}
.gallery-nav-btn.right {
right: 10px;
left: auto;
}
.drop-hover { .drop-hover {
background-color: #e0e0e0; background-color: #e0e0e0;
border: 1px dashed #666; border: 1px dashed #666;
@@ -1944,12 +1896,12 @@ body.dark-mode #folderContextMenu {
transition: transform 0.3s ease, opacity 0.3s ease; transition: transform 0.3s ease, opacity 0.3s ease;
width: 100%; width: 100%;
margin-bottom: 20px; margin-bottom: 20px;
min-height: 353px; min-height: 320px;
} }
#uploadFolderRow.highlight { #uploadFolderRow.highlight {
min-height: 353px; min-height: 320px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -2065,6 +2017,20 @@ body.dark-mode .admin-panel-content label {
animation: spin 1s linear infinite; 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 { .rise-effect {
transform: translateY(-20px); transform: translateY(-20px);
transition: transform 0.3s ease; transition: transform 0.3s ease;
@@ -2119,4 +2085,81 @@ body.dark-mode .header-drop-zone.drag-active {
content: "Drop"; content: "Drop";
font-size: 10px; font-size: 10px;
color: #aaa; 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);
} }

485
public/index.html Normal file
View File

@@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n-key="title">FileRise</title>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('logout') === '1') {
localStorage.removeItem("username");
localStorage.removeItem("userTOTPEnabled");
}
</script>
<link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content="">
<meta name="share-url" content="">
<!-- Google Fonts and Material Icons -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- Bootstrap CSS -->
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css">
<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">
<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 data-i18n-key="header_title">FileRise</h1>
</div>
<div class="header-right">
<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>
</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">
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
<!-- Main Column -->
<div id="mainColumn" class="main-column">
<div class="container-fluid">
<!-- Login Form (unchanged) -->
<div class="row" id="loginForm">
<div class="col-12">
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required />
</div>
<div class="form-group">
<label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required />
</div>
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
<div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
</div>
</form>
<!-- OIDC Login Option -->
<div class="text-center mt-3">
<button id="oidcLoginBtn" class="btn btn-secondary" data-i18n-key="login_oidc">Login with OIDC</button>
</div>
<!-- Basic HTTP Login Option -->
<div class="text-center mt-3">
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic HTTP
Login</a>
</div>
</div>
</div>
<!-- Main Operations: Upload and Folder Management -->
<div id="mainOperations">
<div class="container" style="max-width: 1400px; margin: 0 auto;">
<!-- Top Zone: Two columns (60% and 40%) -->
<div id="uploadFolderRow" class="row">
<!-- 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" 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 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" data-i18n-key="choose_files">Choose Files</button>
</div>
</div>
<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>
</div>
</div>
<!-- Right Column (40% for Folder Management Card) -->
<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 data-i18n-key="folder_navigation">Folder Navigation &amp; 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>
</div>
<div class="card-body custom-folder-card-body">
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
<div id="folderTreeContainer"></div>
</div>
<div class="folder-actions mt-3">
<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 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"
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-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 data-i18n-key="rename_folder_title">Rename Folder</h4>
<input type="text" id="newRenameFolderName" class="form-control"
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"
data-i18n-key="cancel">Cancel</button>
<button id="submitRenameFolder" class="btn btn-primary"
data-i18n-key="rename">Rename</button>
</div>
</div>
</div>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i>
</button>
<button 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 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"
data-i18n-key="cancel">Cancel</button>
<button id="confirmDeleteFolder" class="btn btn-danger"
data-i18n-key="delete">Delete</button>
</div>
</div>
</div>
</div>
<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 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>
</div>
</div>
</div> <!-- end uploadFolderRow -->
</div> <!-- end container -->
</div> <!-- end mainOperations -->
<!-- File List Section -->
<div id="fileListContainer" style="display: none;">
<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;"
data-i18n-key="delete_files">Delete Files</button>
<div id="deleteFilesModal" class="modal">
<div class="modal-content">
<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" 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
data-i18n-key="copy_files">Copy Files</button>
<div id="copyFilesModal" class="modal">
<div class="modal-content">
<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" 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
data-i18n-key="move_files">Move Files</button>
<div id="moveFilesModal" class="modal">
<div class="modal-content">
<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" 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
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 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" data-i18n-key="cancel">Cancel</button>
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button>
</div>
</div>
</div>
</div>
<div id="fileList"></div>
</div>
</div> <!-- end container-fluid -->
</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="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</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 data-i18n-key="create_new_user_title">Create New User</h3>
<label for="newUsername" data-i18n-key="username">Username:</label>
<input type="text" id="newUsername" class="form-control" />
<label for="addUserPassword" data-i18n-key="password">Password:</label>
<input type="password" id="addUserPassword" class="form-control" />
<div id="adminCheckboxContainer">
<input type="checkbox" id="isAdmin" />
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
</div>
<div class="button-container">
<button id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">Save User</button>
</div>
</div>
</div>
<div id="removeUserModal" class="modal">
<div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3>
<label for="removeUsernameSelect" data-i18n-key="select_user_remove">Select a user to remove:</label>
<select id="removeUsernameSelect" class="form-control"></select>
<div class="button-container">
<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 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" data-i18n-key="cancel">Cancel</button>
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button>
</div>
</div>
</div>
<div id="customConfirmModal" class="modal" style="display:none;">
<div class="modal-content">
<p id="confirmMessage"></p>
<div class="modal-actions">
<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="js/main.js"></script>
</body>
</html>

View File

@@ -1,4 +1,5 @@
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
import { t, applyTranslations } from './i18n.js';
import { import {
toggleVisibility, toggleVisibility,
showToast as originalShowToast, showToast as originalShowToast,
@@ -24,7 +25,7 @@ const currentOIDCConfig = {
providerUrl: "https://your-oidc-provider.com", providerUrl: "https://your-oidc-provider.com",
clientId: "YOUR_CLIENT_ID", clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET", clientSecret: "YOUR_CLIENT_SECRET",
redirectUri: "https://yourdomain.com/auth.php?oidc=callback", redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
globalOtpauthUrl: "" globalOtpauthUrl: ""
}; };
window.currentOIDCConfig = currentOIDCConfig; window.currentOIDCConfig = currentOIDCConfig;
@@ -34,14 +35,64 @@ window.currentOIDCConfig = currentOIDCConfig;
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1'; window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
// override showToast to suppress the "Please log in to continue." toast during TOTP // override showToast to suppress the "Please log in to continue." toast during TOTP
function showToast(msg) { function showToast(msgKey) {
if (window.pendingTOTP && msg === "Please log in to continue.") { const msg = t(msgKey);
if (window.pendingTOTP && msgKey === "please_log_in_to_continue") {
return; return;
} }
originalShowToast(msg); originalShowToast(msg);
} }
window.showToast = showToast; window.showToast = showToast;
const originalFetch = window.fetch;
/*
* @param {string} url
* @param {object} options
* @returns {Promise<Response>}
*/
export async function fetchWithCsrf(url, options = {}) {
options = { credentials: 'include', headers: {}, ...options };
options.headers['X-CSRF-Token'] = window.csrfToken;
// 1) First attempt using the original fetch
let res = await originalFetch(url, options);
// 2) Softfailure JSON check (200 + {csrf_expired})
if (res.ok && res.headers.get('content-type')?.includes('application/json')) {
const clone = res.clone();
const data = await clone.json();
if (data.csrf_expired) {
const newToken = data.csrf_token;
window.csrfToken = newToken;
document.querySelector('meta[name="csrf-token"]').content = newToken;
options.headers['X-CSRF-Token'] = newToken;
return originalFetch(url, options);
}
}
// 3) HTTP 403 fallback
if (res.status === 403) {
let newToken = res.headers.get('X-CSRF-Token');
if (!newToken) {
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json();
newToken = body.csrf_token;
}
}
if (newToken) {
window.csrfToken = newToken;
document.querySelector('meta[name="csrf-token"]').content = newToken;
options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options);
}
}
return res;
}
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows // wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
function openTOTPLoginModal() { function openTOTPLoginModal() {
originalOpenTOTPLoginModal(); originalOpenTOTPLoginModal();
@@ -49,7 +100,7 @@ function openTOTPLoginModal() {
const isFormLogin = Boolean(window.__lastLoginData); const isFormLogin = Boolean(window.__lastLoginData);
if (!isFormLogin) { if (!isFormLogin) {
// disable BasicAuth link // disable BasicAuth link
const basicLink = document.querySelector("a[href='login_basic.php']"); const basicLink = document.querySelector("a[href='/api/auth/login_basic.php']");
if (basicLink) { if (basicLink) {
basicLink.style.pointerEvents = 'none'; basicLink.style.pointerEvents = 'none';
basicLink.style.opacity = '0.5'; basicLink.style.opacity = '0.5';
@@ -76,8 +127,9 @@ function updateItemsPerPageSelect() {
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) { function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
const authForm = document.getElementById("authForm"); const authForm = document.getElementById("authForm");
if (authForm) authForm.style.display = disableFormLogin ? "none" : "block"; if (authForm) authForm.style.display = disableFormLogin ? "none" : "block";
const basicAuthLink = document.querySelector("a[href='login_basic.php']"); const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']");
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block"; if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
const oidcLoginBtn = document.getElementById("oidcLoginBtn"); const oidcLoginBtn = document.getElementById("oidcLoginBtn");
if (oidcLoginBtn) oidcLoginBtn.style.display = disableOIDCLogin ? "none" : "inline-block"; if (oidcLoginBtn) oidcLoginBtn.style.display = disableOIDCLogin ? "none" : "inline-block";
@@ -91,22 +143,38 @@ function updateLoginOptionsUIFromStorage() {
}); });
} }
function loadAdminConfigFunc() { export function loadAdminConfigFunc() {
return fetch("getConfig.php", { credentials: "include" }) return fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(response => response.json()) .then(response => response.json())
.then(config => { .then(config => {
localStorage.setItem("headerTitle", config.header_title || "FileRise");
// Update login options using the nested loginOptions object.
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin); localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth); localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin); localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise"); localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
const headerTitleElem = document.querySelector(".header-title h1");
if (headerTitleElem) {
headerTitleElem.textContent = config.header_title || "FileRise";
}
}) })
.catch(() => { .catch(() => {
// Use defaults.
localStorage.setItem("headerTitle", "FileRise");
localStorage.setItem("disableFormLogin", "false"); localStorage.setItem("disableFormLogin", "false");
localStorage.setItem("disableBasicAuth", "false"); localStorage.setItem("disableBasicAuth", "false");
localStorage.setItem("disableOIDCLogin", "false"); localStorage.setItem("disableOIDCLogin", "false");
localStorage.setItem("globalOtpauthUrl", "otpauth://totp/{label}?secret={secret}&issuer=FileRise"); localStorage.setItem("globalOtpauthUrl", "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
const headerTitleElem = document.querySelector(".header-title h1");
if (headerTitleElem) {
headerTitleElem.textContent = "FileRise";
}
}); });
} }
@@ -132,6 +200,8 @@ function updateAuthenticatedUI(data) {
} }
if (typeof data.folderOnly !== "undefined") { if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
} }
const headerButtons = document.querySelector(".header-buttons"); const headerButtons = document.querySelector(".header-buttons");
@@ -143,7 +213,8 @@ function updateAuthenticatedUI(data) {
restoreBtn = document.createElement("button"); restoreBtn = document.createElement("button");
restoreBtn.id = "restoreFilesBtn"; restoreBtn.id = "restoreFilesBtn";
restoreBtn.classList.add("btn", "btn-warning"); restoreBtn.classList.add("btn", "btn-warning");
restoreBtn.innerHTML = '<i class="material-icons" title="Restore/Delete Trash">restore_from_trash</i>'; restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete");
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>';
if (firstButton) insertAfter(restoreBtn, firstButton); if (firstButton) insertAfter(restoreBtn, firstButton);
else headerButtons.appendChild(restoreBtn); else headerButtons.appendChild(restoreBtn);
} }
@@ -154,7 +225,8 @@ function updateAuthenticatedUI(data) {
adminPanelBtn = document.createElement("button"); adminPanelBtn = document.createElement("button");
adminPanelBtn.id = "adminPanelBtn"; adminPanelBtn.id = "adminPanelBtn";
adminPanelBtn.classList.add("btn", "btn-info"); 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); insertAfter(adminPanelBtn, restoreBtn);
adminPanelBtn.addEventListener("click", openAdminPanel); adminPanelBtn.addEventListener("click", openAdminPanel);
} else { } else {
@@ -173,7 +245,9 @@ function updateAuthenticatedUI(data) {
userPanelBtn = document.createElement("button"); userPanelBtn = document.createElement("button");
userPanelBtn.id = "userPanelBtn"; userPanelBtn.id = "userPanelBtn";
userPanelBtn.classList.add("btn", "btn-user"); userPanelBtn.classList.add("btn", "btn-user");
userPanelBtn.innerHTML = '<i class="material-icons" title="User Panel">account_circle</i>'; userPanelBtn.setAttribute("data-i18n-title", "user_panel");
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
const adminBtn = document.getElementById("adminPanelBtn"); const adminBtn = document.getElementById("adminPanelBtn");
if (adminBtn) insertAfter(userPanelBtn, adminBtn); if (adminBtn) insertAfter(userPanelBtn, adminBtn);
else if (firstButton) insertAfter(userPanelBtn, firstButton); else if (firstButton) insertAfter(userPanelBtn, firstButton);
@@ -183,13 +257,13 @@ function updateAuthenticatedUI(data) {
userPanelBtn.style.display = "block"; userPanelBtn.style.display = "block";
} }
} }
applyTranslations();
updateItemsPerPageSelect(); updateItemsPerPageSelect();
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
} }
function checkAuthentication(showLoginToast = true) { function checkAuthentication(showLoginToast = true) {
return sendRequest("checkAuth.php") return sendRequest("/api/auth/checkAuth.php")
.then(data => { .then(data => {
if (data.setup) { if (data.setup) {
window.setupMode = true; window.setupMode = true;
@@ -203,9 +277,18 @@ function checkAuthentication(showLoginToast = true) {
} }
window.setupMode = false; window.setupMode = false;
if (data.authenticated) { if (data.authenticated) {
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
localStorage.setItem("folderOnly", data.folderOnly);
localStorage.setItem("readOnly", data.readOnly);
localStorage.setItem("disableUpload", data.disableUpload);
updateLoginOptionsUIFromStorage();
if (typeof data.totp_enabled !== "undefined") { if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
} }
if (data.csrf_token) {
window.csrfToken = data.csrf_token;
document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
}
updateAuthenticatedUI(data); updateAuthenticatedUI(data);
return data; return data;
} else { } else {
@@ -222,34 +305,73 @@ function checkAuthentication(showLoginToast = true) {
} }
/* ----------------- Authentication Submission ----------------- */ /* ----------------- Authentication Submission ----------------- */
function submitLogin(data) { async function submitLogin(data) {
setLastLoginData(data); setLastLoginData(data);
window.__lastLoginData = data; window.__lastLoginData = data;
sendRequest("auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken })
.then(response => { try {
// ─── 1) Get CSRF for the initial auth call ───
let res = await fetch("/api/auth/token.php", { credentials: "include" });
if (!res.ok) throw new Error("Could not fetch CSRF token");
window.csrfToken = (await res.json()).csrf_token;
// ─── 2) Send credentials ───
const response = await sendRequest(
"/api/auth/auth.php",
"POST",
data,
{ "X-CSRF-Token": window.csrfToken }
);
// ─── 3a) Full login (no TOTP) ───
if (response.success || response.status === "ok") { if (response.success || response.status === "ok") {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!"); sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
window.location.reload(); // … fetch permissions & reload …
} else if (response.totp_required) { try {
openTOTPLoginModal(); const perm = await sendRequest("/api/getUserPermissions.php", "GET");
} else if (response.error && response.error.includes("Too many failed login attempts")) { if (perm && typeof perm === "object") {
showToast(response.error); localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
const loginButton = document.getElementById("authForm").querySelector("button[type='submit']"); localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
if (loginButton) { localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
loginButton.disabled = true;
setTimeout(() => {
loginButton.disabled = false;
showToast("You can now try logging in again.");
}, 30 * 60 * 1000);
} }
} else { } catch { }
showToast("Login failed: " + (response.error || "Unknown error")); return window.location.reload();
}
// ─── 3b) TOTP required ───
if (response.totp_required) {
// **Refresh** CSRF before the TOTP verify call
res = await fetch("/api/auth/token.php", { credentials: "include" });
if (res.ok) {
window.csrfToken = (await res.json()).csrf_token;
} }
}) // now open the modal—any totp_verify fetch from here on will use the new token
.catch(() => { return openTOTPLoginModal();
showToast("Login failed: Unknown error"); }
});
// ─── 3c) Too many attempts ───
if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
const btn = document.querySelector("#authForm button[type='submit']");
if (btn) {
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
showToast("You can now try logging in again.");
}, 30 * 60 * 1000);
}
return;
}
// ─── 3d) Other failures ───
showToast("Login failed: " + (response.error || "Unknown error"));
} catch (err) {
const msg = err.message || err.error || "Unknown error";
showToast(`Login failed: ${msg}`);
}
} }
window.submitLogin = submitLogin; window.submitLogin = submitLogin;
/* ----------------- Other Helpers ----------------- */ /* ----------------- Other Helpers ----------------- */
@@ -274,9 +396,11 @@ function closeRemoveUserModal() {
} }
function loadUserList() { function loadUserList() {
fetch("getUsers.php", { credentials: "include" }) // Updated path: from "getUsers.php" to "api/getUsers.php"
fetch("/api/getUsers.php", { credentials: "include" })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
// Assuming the endpoint returns an array of users.
const users = Array.isArray(data) ? data : (data.users || []); const users = Array.isArray(data) ? data : (data.users || []);
const selectElem = document.getElementById("removeUsernameSelect"); const selectElem = document.getElementById("removeUsernameSelect");
selectElem.innerHTML = ""; selectElem.innerHTML = "";
@@ -291,7 +415,7 @@ function loadUserList() {
closeRemoveUserModal(); closeRemoveUserModal();
} }
}) })
.catch(() => {}); .catch(() => { /* handle errors if needed */ });
} }
window.loadUserList = loadUserList; window.loadUserList = loadUserList;
@@ -314,11 +438,11 @@ function initAuth() {
}); });
} }
document.getElementById("logoutBtn").addEventListener("click", function () { document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("logout.php", { fetch("/api/auth/logout.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken } headers: { "X-CSRF-Token": window.csrfToken }
}).then(() => window.location.reload(true)).catch(() => {}); }).then(() => window.location.reload(true)).catch(() => { });
}); });
document.getElementById("addUserBtn").addEventListener("click", function () { document.getElementById("addUserBtn").addEventListener("click", function () {
resetUserForm(); resetUserForm();
@@ -333,12 +457,12 @@ function initAuth() {
showToast("Username and password are required!"); showToast("Username and password are required!");
return; return;
} }
let url = "addUser.php"; let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1"; if (window.setupMode) url += "?setup=1";
fetch(url, { fetchWithCsrf(url, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin }) body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -368,10 +492,10 @@ function initAuth() {
} }
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?"); const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
if (!confirmed) return; if (!confirmed) return;
fetch("removeUser.php", { fetchWithCsrf("/api/removeUser.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: usernameToRemove }) body: JSON.stringify({ username: usernameToRemove })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -384,7 +508,7 @@ function initAuth() {
showToast("Error: " + (data.error || "Could not remove user")); showToast("Error: " + (data.error || "Could not remove user"));
} }
}) })
.catch(() => {}); .catch(() => { });
}); });
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal); document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
document.getElementById("changePasswordBtn").addEventListener("click", function () { document.getElementById("changePasswordBtn").addEventListener("click", function () {
@@ -407,10 +531,10 @@ function initAuth() {
return; return;
} }
const data = { oldPassword, newPassword, confirmPassword }; const data = { oldPassword, newPassword, confirmPassword };
fetch("changePassword.php", { fetchWithCsrf("/api/changePassword.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
.then(response => response.json()) .then(response => response.json())
@@ -440,7 +564,7 @@ document.addEventListener("DOMContentLoaded", function () {
const oidcLoginBtn = document.getElementById("oidcLoginBtn"); const oidcLoginBtn = document.getElementById("oidcLoginBtn");
if (oidcLoginBtn) { if (oidcLoginBtn) {
oidcLoginBtn.addEventListener("click", () => { oidcLoginBtn.addEventListener("click", () => {
window.location.href = "auth.php?oidc=initiate"; window.location.href = "/api/auth/auth.php?oidc=initiate";
}); });
} }

View File

@@ -1,8 +1,10 @@
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js';
const version = "v1.0.9"; const version = "v1.2.4"; // Update this version string as needed
const adminTitle = `Admin Panel <small style="font-size: 12px; color: gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null; let lastLoginData = null;
export function setLastLoginData(data) { export function setLastLoginData(data) {
@@ -32,18 +34,18 @@ export function openTOTPLoginModal() {
<div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};"> <div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};">
<span id="closeTOTPLoginModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span> <span id="closeTOTPLoginModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span>
<div id="totpSection"> <div id="totpSection">
<h3>Enter TOTP Code</h3> <h3>${t("enter_totp_code")}</h3>
<input type="text" id="totpLoginInput" maxlength="6" <input type="text" id="totpLoginInput" maxlength="6"
style="font-size:24px; text-align:center; width:100%; padding:10px;" style="font-size:24px; text-align:center; width:100%; padding:10px;"
placeholder="6-digit code" /> placeholder="6-digit code" />
</div> </div>
<a href="#" id="toggleRecovery" style="display:block; margin-top:10px; font-size:14px;">Use Recovery Code instead</a> <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;"> <div id="recoverySection" style="display:none; margin-top:10px;">
<h3>Enter Recovery Code</h3> <h3>${t("enter_recovery_code")}</h3>
<input type="text" id="recoveryInput" <input type="text" id="recoveryInput"
style="font-size:24px; text-align:center; width:100%; padding:10px;" style="font-size:24px; text-align:center; width:100%; padding:10px;"
placeholder="Recovery code" /> placeholder="Recovery code" />
<button type="button" id="submitRecovery" class="btn btn-secondary" style="margin-top:10px;">Submit Recovery Code</button> <button type="button" id="submitRecovery" class="btn btn-secondary" style="margin-top:10px;">${t("submit_recovery_code")}</button>
</div> </div>
</div> </div>
`; `;
@@ -65,12 +67,12 @@ export function openTOTPLoginModal() {
// Switch to recovery // Switch to recovery
totpSection.style.display = "none"; totpSection.style.display = "none";
recoverySection.style.display = "block"; recoverySection.style.display = "block";
toggleLink.textContent = "Use TOTP Code instead"; toggleLink.textContent = t("use_totp_code_instead");
} else { } else {
// Switch back to TOTP // Switch back to TOTP
recoverySection.style.display = "none"; recoverySection.style.display = "none";
totpSection.style.display = "block"; totpSection.style.display = "block";
toggleLink.textContent = "Use Recovery Code instead"; toggleLink.textContent = t("use_recovery_code_instead");
} }
}); });
@@ -78,10 +80,10 @@ export function openTOTPLoginModal() {
document.getElementById("submitRecovery").addEventListener("click", () => { document.getElementById("submitRecovery").addEventListener("click", () => {
const recoveryCode = document.getElementById("recoveryInput").value.trim(); const recoveryCode = document.getElementById("recoveryInput").value.trim();
if (!recoveryCode) { if (!recoveryCode) {
showToast("Please enter your recovery code."); showToast(t("please_enter_recovery_code"));
return; return;
} }
fetch("totp_recover.php", { fetch("/api/totp_recover.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -94,49 +96,60 @@ export function openTOTPLoginModal() {
.then(json => { .then(json => {
if (json.status === "ok") { if (json.status === "ok") {
// recovery succeeded → finalize login // recovery succeeded → finalize login
window.location.href = "index.html"; window.location.href = "/index.html";
} else { } else {
showToast(json.message || "Recovery code verification failed"); showToast(json.message || t("recovery_code_verification_failed"));
} }
}) })
.catch(() => { .catch(() => {
showToast("Error verifying recovery code."); showToast(t("error_verifying_recovery_code"));
}); });
}); });
// TOTP submission // TOTP submission
const totpInput = document.getElementById("totpLoginInput"); const totpInput = document.getElementById("totpLoginInput");
totpInput.focus(); totpInput.focus();
totpInput.addEventListener("input", function () {
totpInput.addEventListener("input", async function () {
const code = this.value.trim(); const code = this.value.trim();
if (code.length === 6) { if (code.length !== 6) {
fetch("totp_verify.php", {
method: "POST", return;
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 || "TOTP verification failed");
this.value = "";
totpLoginModal.style.display = "flex";
totpInput.focus();
}
})
.catch(() => {
showToast("TOTP verification failed");
this.value = "";
totpLoginModal.style.display = "flex";
totpInput.focus();
});
} }
const tokenRes = await fetch("/api/auth/token.php", {
credentials: "include"
});
if (!tokenRes.ok) {
showToast(t("totp_verification_failed"));
return;
}
window.csrfToken = (await tokenRes.json()).csrf_token;
const res = await fetch("/api/totp_verify.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_code: code })
});
if (res.ok) {
const json = await res.json();
if (json.status === "ok") {
window.location.href = "/index.html";
return;
}
showToast(json.message || t("totp_verification_failed"));
} else {
showToast(t("totp_verification_failed"));
}
this.value = "";
totpLoginModal.style.display = "flex";
this.focus();
}); });
} else { } else {
// Re-open existing modal // Re-open existing modal
@@ -161,80 +174,114 @@ export function openUserPanel() {
max-width: 600px; max-width: 600px;
width: 90%; width: 90%;
border-radius: 8px; border-radius: 8px;
position: relative; position: fixed;
overflow-y: auto; overflow-y: auto;
max-height: 90vh; max-height: 400px !important;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"}; border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
transform: none; transform: none;
transition: none; transition: none;
`; `;
const savedLanguage = localStorage.getItem("language") || "en";
if (!userPanelModal) { if (!userPanelModal) {
userPanelModal = document.createElement("div"); userPanelModal = document.createElement("div");
userPanelModal.id = "userPanelModal"; userPanelModal.id = "userPanelModal";
userPanelModal.style.cssText = ` userPanelModal.style.cssText = `
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: ${overlayBackground}; background-color: ${overlayBackground};
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 3000; z-index: 3000;
`; `;
userPanelModal.innerHTML = ` userPanelModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content user-panel-content" style="${modalContentStyles}">
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> <span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>User Panel (${username})</h3> <h3>${t("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;"> <button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">
<legend>TOTP Settings</legend> ${t("change_password")}
<div class="form-group"> </button>
<label for="userTOTPEnabled">Enable TOTP:</label>
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" /> <fieldset style="margin-bottom: 15px;">
</div> <legend>${t("totp_settings")}</legend>
</fieldset> <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>
<!-- New API Docs link -->
<div style="margin-bottom: 15px;">
<a href="api.html" target="_blank" class="btn btn-secondary">
${t("api_docs") || "API Docs"}
</a>
</div> </div>
`; </div>
`;
document.body.appendChild(userPanelModal); document.body.appendChild(userPanelModal);
// Handlers…
document.getElementById("closeUserPanel").addEventListener("click", () => { document.getElementById("closeUserPanel").addEventListener("click", () => {
userPanelModal.style.display = "none"; userPanelModal.style.display = "none";
}); });
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => { document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
document.getElementById("changePasswordModal").style.display = "block"; document.getElementById("changePasswordModal").style.display = "block";
}); });
// TOTP checkbox
const totpCheckbox = document.getElementById("userTOTPEnabled"); const totpCheckbox = document.getElementById("userTOTPEnabled");
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true"; totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
totpCheckbox.addEventListener("change", function () { totpCheckbox.addEventListener("change", function () {
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false"); localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
const enabled = this.checked; fetch("/api/updateUserPanel.php", {
fetch("updateUserPanel.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
"Content-Type": "application/json", body: JSON.stringify({ totp_enabled: this.checked })
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_enabled: enabled })
}) })
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
if (!result.success) { if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error);
showToast("Error updating TOTP setting: " + result.error); else if (this.checked) openTOTPModal();
} else if (enabled) {
openTOTPModal();
}
}) })
.catch(() => { showToast("Error updating TOTP setting."); }); .catch(() => showToast(t("error_updating_totp_setting")));
});
// Language selector
const languageSelector = document.getElementById("languageSelector");
languageSelector.value = savedLanguage;
languageSelector.addEventListener("change", function () {
localStorage.setItem("language", this.value);
setLocale(this.value);
applyTranslations();
}); });
} else { } else {
// Update colors if already exists
userPanelModal.style.backgroundColor = overlayBackground; userPanelModal.style.backgroundColor = overlayBackground;
const modalContent = userPanelModal.querySelector(".modal-content"); const modalContent = userPanelModal.querySelector(".modal-content");
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff"; modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000"; modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc"; modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
} }
userPanelModal.style.display = "flex"; userPanelModal.style.display = "flex";
} }
@@ -255,10 +302,10 @@ function showRecoveryCodeModal(recoveryCode) {
`; `;
recoveryModal.innerHTML = ` recoveryModal.innerHTML = `
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;"> <div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
<h3>Your Recovery Code</h3> <h3>${t("your_recovery_code")}</h3>
<p>Please save this code securely. It will not be shown again and can only be used once.</p> <p>${t("please_save_recovery_code")}</p>
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code> <code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
<button type="button" id="closeRecoveryModal" class="btn btn-primary">OK</button> <button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
</div> </div>
`; `;
document.body.appendChild(recoveryModal); document.body.appendChild(recoveryModal);
@@ -297,31 +344,44 @@ export function openTOTPModal() {
z-index: 3100; z-index: 3100;
`; `;
totpModal.innerHTML = ` totpModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content" style="${modalContentStyles}">
<span id="closeTOTPModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> <span id="closeTOTPModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>TOTP Setup</h3> <h3>${t("totp_setup")}</h3>
<p>Scan this QR code with your authenticator app:</p> <p>${t("scan_qr_code")}</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;"> <!-- Create an image placeholder without the CSRF token in the src -->
<br/> <img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
<p>Enter the 6-digit code from your app to confirm setup:</p> <br/>
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" /> <p>${t("enter_totp_confirmation")}</p>
<br/><br/> <input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">Confirm</button> <br/><br/>
</div> <button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
`; </div>
`;
document.body.appendChild(totpModal); document.body.appendChild(totpModal);
loadTOTPQRCode();
document.getElementById("closeTOTPModal").addEventListener("click", () => { document.getElementById("closeTOTPModal").addEventListener("click", () => {
closeTOTPModal(true); closeTOTPModal(true);
}); });
document.getElementById("confirmTOTPBtn").addEventListener("click", function () { document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
const code = document.getElementById("totpConfirmInput").value.trim(); const code = document.getElementById("totpConfirmInput").value.trim();
if (code.length !== 6) { if (code.length !== 6) {
showToast("Please enter a valid 6-digit code."); showToast(t("please_enter_valid_code"));
return; return;
} }
fetch("totp_verify.php", {
const tokenRes = await fetch("/api/auth/token.php", {
credentials: "include"
});
if (!tokenRes.ok) {
showToast(t("error_verifying_totp_code"));
return;
}
const { csrf_token } = await tokenRes.json();
window.csrfToken = csrf_token;
const verifyRes = await fetch("/api/totp_verify.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -329,36 +389,40 @@ export function openTOTPModal() {
"X-CSRF-Token": window.csrfToken "X-CSRF-Token": window.csrfToken
}, },
body: JSON.stringify({ totp_code: code }) body: JSON.stringify({ totp_code: code })
}) });
.then(r => r.json())
.then(result => { if (!verifyRes.ok) {
if (result.status === 'ok') { showToast(t("totp_verification_failed"));
showToast("TOTP successfully enabled."); return;
// After successful TOTP verification, fetch the recovery code }
fetch("totp_saveCode.php", { const result = await verifyRes.json();
method: "POST", if (result.status !== "ok") {
credentials: "include", showToast(result.message || t("totp_verification_failed"));
headers: { return;
"Content-Type": "application/json", }
"X-CSRF-Token": window.csrfToken
} showToast(t("totp_enabled_successfully"));
})
.then(r => r.json()) const saveRes = await fetch("/api/totp_saveCode.php", {
.then(data => { method: "POST",
if (data.status === 'ok' && data.recoveryCode) { credentials: "include",
// Show the recovery code in a secure modal headers: {
showRecoveryCodeModal(data.recoveryCode); "X-CSRF-Token": window.csrfToken
} else { }
showToast("Error generating recovery code: " + (data.message || "Unknown error.")); });
} if (!saveRes.ok) {
}) showToast(t("error_generating_recovery_code"));
.catch(() => { showToast("Error generating recovery code."); }); closeTOTPModal(false);
closeTOTPModal(false); return;
} else { }
showToast("TOTP verification failed: " + (result.message || "Invalid code.")); const data = await saveRes.json();
} if (data.status === "ok" && data.recoveryCode) {
}) showRecoveryCodeModal(data.recoveryCode);
.catch(() => { showToast("Error verifying TOTP code."); }); } else {
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
}
closeTOTPModal(false);
}); });
// Focus the input and attach enter key listener // Focus the input and attach enter key listener
@@ -378,6 +442,13 @@ export function openTOTPModal() {
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff"; modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000"; 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 // Focus the input and attach enter key listener
const totpConfirmInput = document.getElementById("totpConfirmInput"); const totpConfirmInput = document.getElementById("totpConfirmInput");
if (totpConfirmInput) { if (totpConfirmInput) {
@@ -391,6 +462,33 @@ export function openTOTPModal() {
} }
} }
function loadTOTPQRCode() {
fetch("/api/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 // Updated closeTOTPModal function with a disable parameter
export function closeTOTPModal(disable = true) { export function closeTOTPModal(disable = true) {
const totpModal = document.getElementById("totpModal"); const totpModal = document.getElementById("totpModal");
@@ -404,7 +502,7 @@ export function closeTOTPModal(disable = true) {
localStorage.setItem("userTOTPEnabled", "false"); localStorage.setItem("userTOTPEnabled", "false");
} }
// Call endpoint to remove the TOTP secret from the user's record // Call endpoint to remove the TOTP secret from the user's record
fetch("totp_disable.php", { fetch("/api/totp_disable.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -415,19 +513,91 @@ export function closeTOTPModal(disable = true) {
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
if (!result.success) { if (!result.success) {
showToast("Error disabling TOTP setting: " + result.error); showToast(t("error_disabling_totp_setting") + ": " + result.error);
} }
}) })
.catch(() => { showToast("Error disabling TOTP setting."); }); .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() { export function openAdminPanel() {
fetch("getConfig.php", { credentials: "include" }) fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(response => response.json()) .then(response => response.json())
.then(config => { .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.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl; if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const isDarkMode = document.body.classList.contains("dark-mode"); const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = ` const modalContentStyles = `
@@ -442,6 +612,7 @@ export function openAdminPanel() {
max-height: 90vh; max-height: 90vh;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"}; border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
`; `;
let adminModal = document.getElementById("adminPanelModal"); let adminModal = document.getElementById("adminPanelModal");
if (!adminModal) { if (!adminModal) {
@@ -459,98 +630,128 @@ export function openAdminPanel() {
align-items: center; align-items: center;
z-index: 3000; z-index: 3000;
`; `;
// Added a version number next to "Admin Panel"
adminModal.innerHTML = ` adminModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content" style="${modalContentStyles}">
<span id="closeAdminPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> <span id="closeAdminPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3> <h3>${adminTitle}</h3>
<h3>${adminTitle}</h3>
</h3>
<form id="adminPanelForm"> <form id="adminPanelForm">
<fieldset style="margin-bottom: 15px;"> <fieldset style="margin-bottom: 15px;">
<legend>User Management</legend> <legend>${t("user_management")}</legend>
<div style="display: flex; gap: 10px;"> <div style="display: flex; gap: 10px;">
<button type="button" id="adminOpenAddUser" class="btn btn-success">Add User</button> <button type="button" id="adminOpenAddUser" class="btn btn-success">${t("add_user")}</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger">Remove User</button> <button type="button" id="adminOpenRemoveUser" class="btn btn-danger">${t("remove_user")}</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">User Permissions</button> <button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${t("user_permissions")}</button>
</div> </div>
</fieldset> </fieldset>
<fieldset style="margin-bottom: 15px;"> <fieldset style="margin-bottom: 15px;">
<legend>OIDC Configuration</legend> <legend>Header Settings</legend>
<div class="form-group"> <div class="form-group">
<label for="oidcProviderUrl">OIDC Provider URL:</label> <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>
<!-- New WebDAV setting -->
<fieldset style="margin-bottom: 15px;">
<legend>WebDAV Access</legend>
<div class="form-group">
<input type="checkbox" id="enableWebDAV" />
<label for="enableWebDAV">Enable WebDAV</label>
</div>
</fieldset>
<!-- End WebDAV setting -->
<!-- New Shared Max Upload Size setting -->
<fieldset style="margin-bottom: 15px;">
<legend>Shared Max Upload Size (bytes)</legend>
<div class="form-group">
<input type="number" id="sharedMaxUploadSize" class="form-control"
placeholder="e.g. 52428800" />
<small>Enter maximum bytes allowed for shared-folder uploads</small>
</div>
</fieldset>
<!-- End Shared Max Upload Size setting -->
<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}" /> <input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig.providerUrl}" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="oidcClientId">OIDC Client ID:</label> <label for="oidcClientId">${t("oidc_client_id")}:</label>
<input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" /> <input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="oidcClientSecret">OIDC Client Secret:</label> <label for="oidcClientSecret">${t("oidc_client_secret")}:</label>
<input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" /> <input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="oidcRedirectUri">OIDC Redirect URI:</label> <label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label>
<input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig.redirectUri}" /> <input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig.redirectUri}" />
</div> </div>
</fieldset> </fieldset>
<fieldset style="margin-bottom: 15px;"> <fieldset style="margin-bottom: 15px;">
<legend>Global TOTP Settings</legend> <legend>${t("global_totp_settings")}</legend>
<div class="form-group"> <div class="form-group">
<label for="globalOtpauthUrl">Global OTPAuth URL:</label> <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'}" /> <input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
</div> </div>
</fieldset> </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;"> <div style="display: flex; justify-content: space-between;">
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">Cancel</button> <button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveAdminSettings" class="btn btn-primary">Save Settings</button> <button type="button" id="saveAdminSettings" class="btn btn-primary">${t("save_settings")}</button>
</div> </div>
</form> </form>
</div> </div>
`; `;
document.body.appendChild(adminModal); document.body.appendChild(adminModal);
// Bind closing
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
adminModal.addEventListener("click", (e) => { adminModal.addEventListener("click", e => { if (e.target === adminModal) closeAdminPanel(); });
if (e.target === adminModal) closeAdminPanel();
});
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
// Bind other buttons
document.getElementById("adminOpenAddUser").addEventListener("click", () => { document.getElementById("adminOpenAddUser").addEventListener("click", () => {
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus(); document.getElementById("newUsername").focus();
}); });
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => { document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
if (typeof window.loadUserList === "function") { if (typeof window.loadUserList === "function") window.loadUserList();
window.loadUserList();
}
toggleVisibility("removeUserModal", true); toggleVisibility("removeUserModal", true);
}); });
// New event binding for the User Permissions button:
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => { document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
openUserPermissionsModal(); openUserPermissionsModal();
}); });
// Save handler
document.getElementById("saveAdminSettings").addEventListener("click", () => { document.getElementById("saveAdminSettings").addEventListener("click", () => {
const disableFormLoginCheckbox = document.getElementById("disableFormLogin"); const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth"); const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin"); const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length; const enableWebDAVCheckbox = document.getElementById("enableWebDAV");
const sharedMaxUploadSizeInput = document.getElementById("sharedMaxUploadSize");
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox]
.filter(cb => cb.checked).length;
if (totalDisabled === 3) { if (totalDisabled === 3) {
showToast("At least one login method must remain enabled."); showToast(t("at_least_one_login_method"));
disableOIDCLoginCheckbox.checked = false; disableOIDCLoginCheckbox.checked = false;
localStorage.setItem("disableOIDCLogin", "false"); localStorage.setItem("disableOIDCLogin", "false");
if (typeof window.updateLoginOptionsUI === "function") { if (typeof window.updateLoginOptionsUI === "function") {
@@ -562,6 +763,8 @@ export function openAdminPanel() {
} }
return; return;
} }
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
const newOIDCConfig = { const newOIDCConfig = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(), providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(), clientId: document.getElementById("oidcClientId").value.trim(),
@@ -571,37 +774,54 @@ export function openAdminPanel() {
const disableFormLogin = disableFormLoginCheckbox.checked; const disableFormLogin = disableFormLoginCheckbox.checked;
const disableBasicAuth = disableBasicAuthCheckbox.checked; const disableBasicAuth = disableBasicAuthCheckbox.checked;
const disableOIDCLogin = disableOIDCLoginCheckbox.checked; const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
const enableWebDAV = enableWebDAVCheckbox.checked;
const sharedMaxUploadSize = parseInt(sharedMaxUploadSizeInput.value, 10) || 0;
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim(); const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
sendRequest("updateConfig.php", "POST", {
sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: newHeaderTitle,
oidc: newOIDCConfig, oidc: newOIDCConfig,
disableFormLogin, disableFormLogin,
disableBasicAuth, disableBasicAuth,
disableOIDCLogin, disableOIDCLogin,
enableWebDAV,
sharedMaxUploadSize,
globalOtpauthUrl globalOtpauthUrl
}, { "X-CSRF-Token": window.csrfToken }) }, { "X-CSRF-Token": window.csrfToken })
.then(response => { .then(response => {
if (response.success) { if (response.success) {
showToast("Settings updated successfully."); showToast(t("settings_updated_successfully"));
localStorage.setItem("disableFormLogin", disableFormLogin); localStorage.setItem("disableFormLogin", disableFormLogin);
localStorage.setItem("disableBasicAuth", disableBasicAuth); localStorage.setItem("disableBasicAuth", disableBasicAuth);
localStorage.setItem("disableOIDCLogin", disableOIDCLogin); localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
localStorage.setItem("enableWebDAV", enableWebDAV);
localStorage.setItem("sharedMaxUploadSize", sharedMaxUploadSize);
if (typeof window.updateLoginOptionsUI === "function") { if (typeof window.updateLoginOptionsUI === "function") {
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }); window.updateLoginOptionsUI({
disableFormLogin,
disableBasicAuth,
disableOIDCLogin
});
} }
captureInitialAdminConfig();
closeAdminPanel(); closeAdminPanel();
loadAdminConfigFunc();
} else { } else {
showToast("Error updating settings: " + (response.error || "Unknown error")); showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error")));
} }
}) })
.catch(() => { }); .catch(() => { });
}); });
// Enforce login option constraints.
const disableFormLoginCheckbox = document.getElementById("disableFormLogin"); const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth"); const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin"); const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
function enforceLoginOptionConstraint(changedCheckbox) { function enforceLoginOptionConstraint(changedCheckbox) {
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length; const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox]
.filter(cb => cb.checked).length;
if (changedCheckbox.checked && totalDisabled === 3) { if (changedCheckbox.checked && totalDisabled === 3) {
showToast("At least one login method must remain enabled."); showToast(t("at_least_one_login_method"));
changedCheckbox.checked = false; changedCheckbox.checked = false;
} }
} }
@@ -609,10 +829,17 @@ export function openAdminPanel() {
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
// Initial checkbox and input states
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
captureInitialAdminConfig();
} else { } else {
// Update existing modal and show
adminModal.style.backgroundColor = overlayBackground; adminModal.style.backgroundColor = overlayBackground;
const modalContent = adminModal.querySelector(".modal-content"); const modalContent = adminModal.querySelector(".modal-content");
if (modalContent) { if (modalContent) {
@@ -628,7 +855,10 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
adminModal.style.display = "flex"; adminModal.style.display = "flex";
captureInitialAdminConfig();
} }
}) })
.catch(() => { .catch(() => {
@@ -649,20 +879,28 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true"; document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true"; document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true"; document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
document.getElementById("enableWebDAV").checked = localStorage.getItem("enableWebDAV") === "true";
document.getElementById("sharedMaxUploadSize").value = localStorage.getItem("sharedMaxUploadSize") || "";
adminModal.style.display = "flex"; adminModal.style.display = "flex";
captureInitialAdminConfig();
} else { } else {
openAdminPanel(); openAdminPanel();
} }
}); });
} }
export function closeAdminPanel() { export async function closeAdminPanel() {
if (hasUnsavedChanges()) {
const userConfirmed = await showCustomConfirmModal(t("unsaved_changes_confirm"));
if (!userConfirmed) {
return;
}
}
const adminModal = document.getElementById("adminPanelModal"); const adminModal = document.getElementById("adminPanelModal");
if (adminModal) adminModal.style.display = "none"; if (adminModal) adminModal.style.display = "none";
} }
// --- New: User Permissions Modal --- // --- New: User Permissions Modal ---
export function openUserPermissionsModal() { export function openUserPermissionsModal() {
let userPermissionsModal = document.getElementById("userPermissionsModal"); let userPermissionsModal = document.getElementById("userPermissionsModal");
const isDarkMode = document.body.classList.contains("dark-mode"); const isDarkMode = document.body.classList.contains("dark-mode");
@@ -695,13 +933,13 @@ export function openUserPermissionsModal() {
userPermissionsModal.innerHTML = ` userPermissionsModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPermissionsModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> <span id="closeUserPermissionsModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>User Permissions</h3> <h3>${t("user_permissions")}</h3>
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;"> <div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
<!-- User rows will be loaded here --> <!-- User rows will be loaded here -->
</div> </div>
<div style="display: flex; justify-content: flex-end; gap: 10px;"> <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="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">Save Permissions</button> <button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">${t("save_permissions")}</button>
</div> </div>
</div> </div>
`; `;
@@ -729,17 +967,17 @@ export function openUserPermissionsModal() {
}); });
}); });
// Send the permissionsData to the server. // Send the permissionsData to the server.
sendRequest("updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken }) sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
.then(response => { .then(response => {
if (response.success) { if (response.success) {
showToast("User permissions updated successfully."); showToast(t("user_permissions_updated_successfully"));
userPermissionsModal.style.display = "none"; userPermissionsModal.style.display = "none";
} else { } else {
showToast("Error updating permissions: " + (response.error || "Unknown error")); showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error")));
} }
}) })
.catch(() => { .catch(() => {
showToast("Error updating permissions."); showToast(t("error_updating_permissions"));
}); });
}); });
} else { } else {
@@ -755,29 +993,35 @@ function loadUserPermissionsList() {
listContainer.innerHTML = ""; listContainer.innerHTML = "";
// First, fetch the current permissions from the server. // First, fetch the current permissions from the server.
fetch("getUserPermissions.php", { credentials: "include" }) fetch("/api/getUserPermissions.php", { credentials: "include" })
.then(response => response.json()) .then(response => response.json())
.then(permissionsData => { .then(permissionsData => {
// Then, fetch the list of users. // Then, fetch the list of users.
return fetch("getUsers.php", { credentials: "include" }) return fetch("/api/getUsers.php", { credentials: "include" })
.then(response => response.json()) .then(response => response.json())
.then(usersData => { .then(usersData => {
const users = Array.isArray(usersData) ? usersData : (usersData.users || []); const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
if (users.length === 0) { if (users.length === 0) {
listContainer.innerHTML = "<p>No users found.</p>"; listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
return; return;
} }
users.forEach(user => { users.forEach(user => {
// Skip admin users. // Skip admin users.
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return; if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
// Use stored permissions if available; otherwise fall back to localStorage defaults. // Use stored permissions if available; otherwise fall back to defaults.
const defaultPerm = { const defaultPerm = {
folderOnly: localStorage.getItem("folderOnly") === "true", folderOnly: false,
readOnly: localStorage.getItem("readOnly") === "true", readOnly: false,
disableUpload: localStorage.getItem("disableUpload") === "true" disableUpload: false,
}; };
const userPerm = (permissionsData && typeof permissionsData === "object" && permissionsData[user.username]) || defaultPerm;
// 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. // Create a row for the user.
const row = document.createElement("div"); const row = document.createElement("div");
@@ -789,15 +1033,15 @@ function loadUserPermissionsList() {
<div style="display: flex; flex-direction: column; gap: 5px;"> <div style="display: flex; flex-direction: column; gap: 5px;">
<label style="display: flex; align-items: center; gap: 5px;"> <label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} /> <input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
User Folder Only ${t("user_folder_only")}
</label> </label>
<label style="display: flex; align-items: center; gap: 5px;"> <label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} /> <input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
Read Only ${t("read_only")}
</label> </label>
<label style="display: flex; align-items: center; gap: 5px;"> <label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} /> <input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
Disable Upload ${t("disable_upload")}
</label> </label>
</div> </div>
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;"> <hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
@@ -807,6 +1051,6 @@ function loadUserPermissionsList() {
}); });
}) })
.catch(() => { .catch(() => {
listContainer.innerHTML = "<p>Error loading users.</p>"; listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
}); });
} }

View File

@@ -1,4 +1,6 @@
// domUtils.js // domUtils.js
import { t } from './i18n.js';
import { openDownloadModal } from './fileActions.js';
// Basic DOM Helpers // Basic DOM Helpers
export function toggleVisibility(elementId, shouldShow) { export function toggleVisibility(elementId, shouldShow) {
@@ -6,7 +8,7 @@ export function toggleVisibility(elementId, shouldShow) {
if (element) { if (element) {
element.style.display = shouldShow ? "block" : "none"; element.style.display = shouldShow ? "block" : "none";
} else { } 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 }) { export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) {
const safeSearchTerm = escapeHTML(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 ` return `
<div class="row align-items-center mb-3"> <div class="row align-items-center mb-3">
<div class="col-12 col-md-8 mb-2 mb-md-0"> <div class="col-12 col-md-8 mb-2 mb-md-0">
<div class="input-group"> <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"> <div class="input-group-prepend">
<span class="input-group-text" id="searchIcon"> <span class="input-group-text" id="searchIcon">
<i class="material-icons">search</i> <i class="material-icons">search</i>
</span> </span>
</div> </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> </div>
<div class="col-12 col-md-4 text-left"> <div class="col-12 col-md-4 text-left">
<div class="d-flex justify-content-center justify-content-md-start align-items-center"> <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> <button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${t("prev")}</button>
<span class="page-indicator">Page ${currentPage} of ${totalPages || 1}</span> <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})">Next</button> <button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">${t("next")}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -117,12 +132,12 @@ export function buildFileTableHeader(sortOrder) {
<thead> <thead>
<tr> <tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th> <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="name" class="sortable-col">${t("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="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">Upload Date ${sortOrder.column === "uploaded" ? (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">File Size ${sortOrder.column === "size" ? (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">Uploader ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>Actions</th> <th>${t("actions")}</th>
</tr> </tr>
</thead> </thead>
`; `;
@@ -164,38 +179,40 @@ export function buildFileTableRow(file, folderPath) {
<td class="hide-small hide-medium nowrap">${safeUploader}</td> <td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td> <td>
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;"> <div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
<a class="btn btn-sm btn-success download-btn" <button type="button" class="btn btn-sm btn-success download-btn"
href="download.php?folder=${encodeURIComponent(file.folder || 'root')}&file=${encodeURIComponent(file.name)}" onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')"
title="Download"> title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</a> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" <button class="btn btn-sm edit-btn"
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="Edit"> title="${t('edit')}">
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</button> </button>
` : ""} ` : ""}
${previewButton} ${previewButton}
<button class="btn btn-sm btn-warning rename-btn" <button class="btn btn-sm btn-warning rename-btn"
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' 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> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
`; `;
} }
export function buildBottomControls(itemsPerPageSetting) { export function buildBottomControls(itemsPerPageSetting) {
return ` return `
<div class="d-flex align-items-center mt-3 bottom-controls"> <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)"> <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> </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> </div>
`; `;
} }
@@ -221,15 +238,63 @@ export function updateRowHighlight(checkbox) {
} }
export function toggleRowSelection(event, fileName) { 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(); const targetTag = event.target.tagName.toLowerCase();
if (targetTag === 'a' || targetTag === 'button' || targetTag === 'input') { if (["a", "button", "input"].includes(targetTag)) {
return; return;
} }
// Get the clicked row and its checkbox.
const row = event.currentTarget; const row = event.currentTarget;
const checkbox = row.querySelector('.file-checkbox'); const checkbox = row.querySelector(".file-checkbox");
if (!checkbox) return; 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(); updateFileActionButtons();
} }
@@ -239,7 +304,7 @@ export function attachEnterKeyListener(modalId, buttonId) {
// Make the modal focusable // Make the modal focusable
modal.setAttribute("tabindex", "-1"); modal.setAttribute("tabindex", "-1");
modal.focus(); modal.focus();
modal.addEventListener("keydown", function(e) { modal.addEventListener("keydown", function (e) {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById(buttonId); const btn = document.getElementById(buttonId);

View File

@@ -299,7 +299,7 @@ export function loadSidebarOrder() {
modal = document.createElement('div'); modal = document.createElement('div');
modal.className = 'header-card-modal'; modal.className = 'header-card-modal';
modal.style.position = 'fixed'; modal.style.position = 'fixed';
modal.style.top = '80px'; modal.style.top = '55px';
modal.style.right = '80px'; modal.style.right = '80px';
modal.style.zIndex = '11000'; modal.style.zIndex = '11000';
// Render the modal but initially keep it hidden. // Render the modal but initially keep it hidden.

View File

@@ -2,18 +2,20 @@
import { showToast, attachEnterKeyListener } from './domUtils.js'; import { showToast, attachEnterKeyListener } from './domUtils.js';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js';
import { formatFolderName } from './fileListView.js'; import { formatFolderName } from './fileListView.js';
import { t } from './i18n.js';
export function handleDeleteSelected(e) { export function handleDeleteSelected(e) {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
const checkboxes = document.querySelectorAll(".file-checkbox:checked"); const checkboxes = document.querySelectorAll(".file-checkbox:checked");
if (checkboxes.length === 0) { if (checkboxes.length === 0) {
showToast("No files selected."); showToast("no_files_selected");
return; return;
} }
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value); window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
document.getElementById("deleteFilesMessage").textContent = const count = window.filesToDelete.length;
"Are you sure you want to delete " + window.filesToDelete.length + " selected file(s)?"; document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
document.getElementById("deleteFilesModal").style.display = "block"; document.getElementById("deleteFilesModal").style.display = "block";
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles"); attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
} }
@@ -26,11 +28,11 @@ document.addEventListener("DOMContentLoaded", function () {
window.filesToDelete = []; window.filesToDelete = [];
}); });
} }
const confirmDelete = document.getElementById("confirmDeleteFiles"); const confirmDelete = document.getElementById("confirmDeleteFiles");
if (confirmDelete) { if (confirmDelete) {
confirmDelete.addEventListener("click", function () { confirmDelete.addEventListener("click", function () {
fetch("deleteFiles.php", { fetch("/api/file/deleteFiles.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -74,6 +76,81 @@ export function handleDownloadZipSelected(e) {
}, 100); }, 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 = "/api/file/download.php?folder=" + encodeURIComponent(folder) +
"&file=" + encodeURIComponent(window.singleFileToDownload);
fetch(downloadURL, {
method: "GET",
credentials: "include"
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error("Failed to download file: " + text);
});
}
return response.blob();
})
.then(blob => {
if (!blob || blob.size === 0) {
throw new Error("Received empty file.");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
// Hide the progress modal.
document.getElementById("downloadProgressModal").style.display = "none";
showToast("Download started.");
})
.catch(error => {
// Hide progress modal and show error.
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error downloading file:", error);
showToast("Error downloading file: " + error.message);
});
}
export function handleExtractZipSelected(e) { export function handleExtractZipSelected(e) {
if (e) { if (e) {
e.preventDefault(); e.preventDefault();
@@ -91,7 +168,17 @@ export function handleExtractZipSelected(e) {
showToast("No zip files selected."); showToast("No zip files selected.");
return; return;
} }
fetch("extractZip.php", {
// 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("/api/file/extractZip.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -105,6 +192,8 @@ export function handleExtractZipSelected(e) {
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
// Hide the progress modal once the request has completed.
document.getElementById("downloadProgressModal").style.display = "none";
if (data.success) { if (data.success) {
let toastMessage = "Zip file(s) extracted successfully!"; let toastMessage = "Zip file(s) extracted successfully!";
if (data.extractedFiles && Array.isArray(data.extractedFiles) && data.extractedFiles.length) { if (data.extractedFiles && Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
@@ -117,6 +206,8 @@ export function handleExtractZipSelected(e) {
} }
}) })
.catch(error => { .catch(error => {
// Hide the progress modal on error.
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error extracting zip files:", error); console.error("Error extracting zip files:", error);
showToast("Error extracting zip files."); showToast("Error extracting zip files.");
}); });
@@ -135,7 +226,8 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("downloadZipModal").style.display = "none"; document.getElementById("downloadZipModal").style.display = "none";
}); });
} }
// This part remains in your confirmDownloadZip event handler:
const confirmDownloadZip = document.getElementById("confirmDownloadZip"); const confirmDownloadZip = document.getElementById("confirmDownloadZip");
if (confirmDownloadZip) { if (confirmDownloadZip) {
confirmDownloadZip.addEventListener("click", function () { confirmDownloadZip.addEventListener("click", function () {
@@ -147,9 +239,13 @@ document.addEventListener("DOMContentLoaded", function () {
if (!zipName.toLowerCase().endsWith(".zip")) { if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip"; zipName += ".zip";
} }
// Hide the ZIP name input modal
document.getElementById("downloadZipModal").style.display = "none"; 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"; const folder = window.currentFolder || "root";
fetch("downloadZip.php", { fetch("/api/file/downloadZip.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -179,9 +275,13 @@ document.addEventListener("DOMContentLoaded", function () {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
a.remove(); a.remove();
// Hide the progress modal after download starts
document.getElementById("downloadProgressModal").style.display = "none";
showToast("Download started."); showToast("Download started.");
}) })
.catch(error => { .catch(error => {
// Hide the progress modal on error
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error downloading zip:", error); console.error("Error downloading zip:", error);
showToast("Error downloading selected files as zip: " + error.message); showToast("Error downloading selected files as zip: " + error.message);
}); });
@@ -209,7 +309,7 @@ export async function loadCopyMoveFolderListForModal(dropdownId) {
if (window.userFolderOnly) { if (window.userFolderOnly) {
const username = localStorage.getItem("username") || "root"; const username = localStorage.getItem("username") || "root";
try { try {
const response = await fetch("getFolderList.php?restricted=1"); const response = await fetch("/api/folder/getFolderList.php?restricted=1");
let folders = await response.json(); let folders = await response.json();
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) { if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder); folders = folders.map(item => item.folder);
@@ -218,12 +318,12 @@ export async function loadCopyMoveFolderListForModal(dropdownId) {
folder.toLowerCase() !== "trash" && folder.toLowerCase() !== "trash" &&
(folder === username || folder.indexOf(username + "/") === 0) (folder === username || folder.indexOf(username + "/") === 0)
); );
const rootOption = document.createElement("option"); const rootOption = document.createElement("option");
rootOption.value = username; rootOption.value = username;
rootOption.textContent = formatFolderName(username); rootOption.textContent = formatFolderName(username);
folderSelect.appendChild(rootOption); folderSelect.appendChild(rootOption);
folders.forEach(folder => { folders.forEach(folder => {
if (folder !== username) { if (folder !== username) {
const option = document.createElement("option"); const option = document.createElement("option");
@@ -237,20 +337,20 @@ export async function loadCopyMoveFolderListForModal(dropdownId) {
} }
return; return;
} }
try { try {
const response = await fetch("getFolderList.php"); const response = await fetch("/api/folder/getFolderList.php");
let folders = await response.json(); let folders = await response.json();
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) { if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder); folders = folders.map(item => item.folder);
} }
folders = folders.filter(folder => folder !== "root" && folder.toLowerCase() !== "trash"); folders = folders.filter(folder => folder !== "root" && folder.toLowerCase() !== "trash");
const rootOption = document.createElement("option"); const rootOption = document.createElement("option");
rootOption.value = "root"; rootOption.value = "root";
rootOption.textContent = "(Root)"; rootOption.textContent = "(Root)";
folderSelect.appendChild(rootOption); folderSelect.appendChild(rootOption);
if (Array.isArray(folders) && folders.length > 0) { if (Array.isArray(folders) && folders.length > 0) {
folders.forEach(folder => { folders.forEach(folder => {
const option = document.createElement("option"); const option = document.createElement("option");
@@ -297,7 +397,7 @@ document.addEventListener("DOMContentLoaded", function () {
showToast("Error: Cannot copy files to the same folder."); showToast("Error: Cannot copy files to the same folder.");
return; return;
} }
fetch("copyFiles.php", { fetch("/api/file/copyFiles.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -348,7 +448,7 @@ document.addEventListener("DOMContentLoaded", function () {
showToast("Error: Cannot move files to the same folder."); showToast("Error: Cannot move files to the same folder.");
return; return;
} }
fetch("moveFiles.php", { fetch("/api/file/moveFiles.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@@ -414,7 +514,7 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
const folderUsed = window.fileFolder; const folderUsed = window.fileFolder;
fetch("renameFile.php", { fetch("/api/file/renameFile.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {

View File

@@ -1,4 +1,4 @@
// dragDrop.js // fileDragDrop.js
import { showToast } from './domUtils.js'; import { showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js';
@@ -96,7 +96,7 @@ export function folderDropHandler(event) {
return; return;
} }
if (!dragData || !dragData.fileName) return; if (!dragData || !dragData.fileName) return;
fetch("moveFiles.php", { fetch("/api/file/moveFiles.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {

View File

@@ -1,6 +1,7 @@
// editor.js // fileEditor.js
import { escapeHTML, showToast } from './domUtils.js'; import { escapeHTML, showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js';
import { t } from './i18n.js';
function getModeForFile(fileName) { function getModeForFile(fileName) {
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
@@ -73,17 +74,17 @@ export function editFile(fileName, folder) {
modal.classList.add("modal", "editor-modal"); modal.classList.add("modal", "editor-modal");
modal.innerHTML = ` modal.innerHTML = `
<div class="editor-header"> <div class="editor-header">
<h3 class="editor-title">Editing: ${escapeHTML(fileName)}</h3> <h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3>
<div class="editor-controls"> <div class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">A-</button> <button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">A+</button> <button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
</div> </div>
<button id="closeEditorX" class="editor-close-btn">&times;</button> <button id="closeEditorX" class="editor-close-btn">&times;</button>
</div> </div>
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea> <textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
<div class="editor-footer"> <div class="editor-footer">
<button id="saveBtn" class="btn btn-primary">Save</button> <button id="saveBtn" class="btn btn-primary">${t("save")}</button>
<button id="closeBtn" class="btn btn-secondary">Close</button> <button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
@@ -159,7 +160,7 @@ export function saveFile(fileName, folder) {
content: editor.getValue(), content: editor.getValue(),
folder: folderUsed folder: folderUsed
}; };
fetch("saveFile.php", { fetch("/api/file/saveFile.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {

768
public/js/fileListView.js Normal file
View File

@@ -0,0 +1,768 @@
// 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 = parseInt(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
10
);
window.currentPage = window.currentPage || 1;
window.viewMode = localStorage.getItem("viewMode") || "table";
// 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} &nbsp;|&nbsp; <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);
}
window.imageCache = window.imageCache || {};
function cacheImage(imgElem, key) {
// Save the current src for future renders.
window.imageCache[key] = imgElem.src;
}
window.cacheImage = cacheImage;
/**
* --- 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("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
.then(response => {
if (response.status === 401) {
showToast("Session expired. Please log in again.");
window.location.href = "/api/auth/logout.php";
throw new Error("Unauthorized");
}
return response.json();
})
.then(data => {
fileListContainer.innerHTML = ""; // Clear loading message.
if (data.files && Object.keys(data.files).length > 0) {
// If the returned "files" is an object instead of an array, transform it.
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
// Process each file add computed properties.
data.files = data.files.map(file => {
file.fullName = (file.path || file.name).trim().toLowerCase();
file.editable = canEditFile(file.name);
file.folder = folder;
if (!file.type && /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
file.type = "image";
}
// OPTIONAL: For text documents, preload content (if available from backend)
// Example: if (/\.txt|html|md|js|css|json|xml$/i.test(file.name)) { file.content = file.content || ""; }
return file;
});
fileData = data.files;
// Update file summary.
const actionsContainer = document.getElementById("fileListActions");
if (actionsContainer) {
let summaryElem = document.getElementById("fileSummary");
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.float = "right";
summaryElem.style.marginLeft = "auto";
summaryElem.style.marginRight = "60px";
summaryElem.style.fontSize = "0.9em";
actionsContainer.appendChild(summaryElem);
} else {
summaryElem.style.display = "block";
}
summaryElem.innerHTML = buildFolderSummary(fileData);
}
// Render view based on the view mode.
if (window.viewMode === "gallery") {
renderGalleryView(folder);
updateFileActionButtons();
} else {
renderFileTable(folder);
}
} else {
fileListContainer.textContent = t("no_files_found");
const summaryElem = document.getElementById("fileSummary");
if (summaryElem) {
summaryElem.style.display = "none";
}
updateFileActionButtons();
}
return data.files || [];
})
.catch(error => {
console.error("Error loading file list:", error);
if (error.message !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
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();
}
// A helper to compute the max image height based on the current column count.
function getMaxImageHeight() {
const columns = parseInt(window.galleryColumns || 3, 10);
return 150 * (7 - columns); // adjust the multiplier as needed.
}
export function renderGalleryView(folder, container) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const filteredFiles = searchFiles(searchTerm);
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
// pagination settings
const itemsPerPage = window.itemsPerPage;
let currentPage = window.currentPage || 1;
const totalFiles = filteredFiles.length;
const totalPages = Math.ceil(totalFiles / itemsPerPage);
if (currentPage > totalPages) {
currentPage = totalPages || 1;
window.currentPage = currentPage;
}
// --- Top controls: search + pagination + items-per-page ---
let galleryHTML = buildSearchAndPaginationControls({
currentPage,
totalPages,
searchTerm: window.currentSearchTerm || ""
});
// wire up search input just like table view
setTimeout(() => {
const searchInput = document.getElementById("searchInput");
if (searchInput) {
searchInput.addEventListener("input", debounce(() => {
window.currentSearchTerm = searchInput.value;
window.currentPage = 1;
renderGalleryView(folder);
// keep caret at end
setTimeout(() => {
const f = document.getElementById("searchInput");
if (f) {
f.focus();
const len = f.value.length;
f.setSelectionRange(len, len);
}
}, 0);
}, 300));
}
}, 0);
// --- Column slider ---
const numColumns = window.galleryColumns || 3;
galleryHTML += `
<div class="gallery-slider" style="margin:10px; text-align:center;">
<label for="galleryColumnsSlider" style="margin-right:5px;">
${t('columns')}:
</label>
<input type="range" id="galleryColumnsSlider" min="1" max="6"
value="${numColumns}" style="vertical-align:middle;">
<span id="galleryColumnsValue">${numColumns}</span>
</div>
`;
// --- Start gallery grid ---
galleryHTML += `
<div class="gallery-container"
style="display:grid;
grid-template-columns:repeat(${numColumns},1fr);
gap:10px;
padding:10px;">
`;
// slice current page
const startIdx = (currentPage - 1) * itemsPerPage;
const pageFiles = filteredFiles.slice(startIdx, startIdx + itemsPerPage);
pageFiles.forEach((file, idx) => {
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
// thumbnail
let thumbnail;
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
const cacheKey = folderPath + encodeURIComponent(file.name);
if (window.imageCache && window.imageCache[cacheKey]) {
thumbnail = `<img src="${window.imageCache[cacheKey]}"
class="gallery-thumbnail"
alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} else {
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
thumbnail = `<img src="${imageUrl}"
onload="cacheImage(this,'${cacheKey}')"
class="gallery-thumbnail"
alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
}
} 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>`;
}
// tag badges
let tagBadgesHTML = "";
if (file.tags && file.tags.length) {
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>`;
}
// card with checkbox, preview, info, buttons
galleryHTML += `
<div class="gallery-card"
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
<input type="checkbox"
class="file-checkbox"
id="cb-${idSafe}"
value="${escapeHTML(file.name)}"
style="position:absolute; top:5px; left:5px; z-index:10;">
<label for="cb-${idSafe}"
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview"
style="cursor:pointer;"
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')">
${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;">
${escapeHTML(file.name)}
</span>
${tagBadgesHTML}
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
<button type="button" class="btn btn-sm btn-success download-btn"
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')"
title="${t('download')}">
<i class="material-icons">file_download</i>
</button>
${file.editable ? `
<button class="btn btn-sm edit-btn"
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('Edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
<button class="btn btn-sm btn-warning rename-btn"
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<button class="btn btn-sm btn-secondary share-btn"
data-file="${escapeHTML(file.name)}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
</div>
</div>
</div>
`;
});
galleryHTML += `</div>`; // end gallery-container
// bottom controls
galleryHTML += buildBottomControls(itemsPerPage);
// render
fileListContent.innerHTML = galleryHTML;
// ensure toggle button
createViewToggleButton();
// attach listeners
// checkboxes
document.querySelectorAll(".file-checkbox").forEach(cb => {
cb.addEventListener("change", () => updateFileActionButtons());
});
// slider
const slider = document.getElementById("galleryColumnsSlider");
if (slider) {
slider.addEventListener("input", () => {
const v = +slider.value;
document.getElementById("galleryColumnsValue").textContent = v;
window.galleryColumns = v;
document.querySelector(".gallery-container")
.style.gridTemplateColumns = `repeat(${v},1fr)`;
document.querySelectorAll(".gallery-thumbnail")
.forEach(img => img.style.maxHeight = getMaxImageHeight() + "px");
});
}
// pagination
window.changePage = newPage => {
window.currentPage = newPage;
if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder);
};
// items per page
window.changeItemsPerPage = cnt => {
window.itemsPerPage = +cnt;
localStorage.setItem("itemsPerPage", cnt);
window.currentPage = 1;
if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder);
};
// update toolbar buttons
updateFileActionButtons();
}
// Responsive slider constraints based on screen size.
function updateSliderConstraints() {
const slider = document.getElementById("galleryColumnsSlider");
if (!slider) return;
const width = window.innerWidth;
let min = 1;
let max;
// Set maximum based on screen size.
if (width < 600) { // small devices (phones)
max = 1;
} else if (width < 1024) { // medium devices
max = 3;
} else if (width < 1440) { // between medium and large devices
max = 4;
} else { // large devices and above
max = 6;
}
// Adjust the slider's current value if needed
let currentVal = parseInt(slider.value, 10);
if (currentVal > max) {
currentVal = max;
slider.value = max;
}
slider.min = min;
slider.max = max;
document.getElementById("galleryColumnsValue").textContent = currentVal;
// Update the grid layout based on the current slider value.
const galleryContainer = document.querySelector(".gallery-container");
if (galleryContainer) {
galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`;
}
}
window.addEventListener('load', updateSliderConstraints);
window.addEventListener('resize', updateSliderConstraints);
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;
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
};
window.changeItemsPerPage = function (newCount) {
window.itemsPerPage = parseInt(newCount, 10);
localStorage.setItem('itemsPerPage', newCount);
window.currentPage = 1;
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
};
// fileListView.js (bottom)
window.loadFileList = loadFileList;
window.renderFileTable = renderFileTable;
window.renderGalleryView = renderGalleryView;
window.sortFiles = sortFiles;
window.toggleAdvancedSearch = toggleAdvancedSearch;

Some files were not shown because too many files have changed in this diff Show More