Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd5a8664a | ||
|
|
0cb47b4054 | ||
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 | ||
|
|
4f39b3a41e | ||
|
|
40cecc10ad | ||
|
|
aee78c9750 | ||
|
|
16ccb66d55 | ||
|
|
9209f7a582 | ||
|
|
4a736b0224 | ||
|
|
f162a7d0d7 | ||
|
|
3fc526df7f | ||
|
|
20422cf5a7 | ||
|
|
492bab36ca | ||
|
|
f2f7697994 | ||
|
|
13aa011632 | ||
|
|
1add160f5d | ||
|
|
87368143b5 | ||
|
|
939aa032f0 | ||
|
|
fbd21a035b |
310
CHANGELOG.md
310
CHANGELOG.md
@@ -1,5 +1,315 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 10/6/2025 v1.3.15
|
||||||
|
|
||||||
|
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
|
||||||
|
|
||||||
|
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
|
||||||
|
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
|
||||||
|
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
|
||||||
|
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/5/2025 v1.3.14
|
||||||
|
|
||||||
|
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||||
|
|
||||||
|
- AdminModel::updateConfig now enforces OIDC fields only if disableOIDCLogin=false
|
||||||
|
- AdminModel::getConfig defaults disableOIDCLogin=true and guarantees OIDC keys
|
||||||
|
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
|
||||||
|
- Normalize file perms to 0664 after write
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.13
|
||||||
|
|
||||||
|
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||||
|
fix(scanner): rebuild per-folder metadata to match File/Folder models
|
||||||
|
chore(scanner): skip profile_pics subtree during scans
|
||||||
|
|
||||||
|
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
|
||||||
|
- prevents double slashes in metadata paths; respects app timezone
|
||||||
|
- unblocks SCAN_ON_START so externally added files are indexed at boot
|
||||||
|
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
|
||||||
|
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
|
||||||
|
- Prunes stale entries for files that no longer exist
|
||||||
|
- Skips uploads/trash and symlinks
|
||||||
|
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
|
||||||
|
- Idempotent; safe to run at startup via SCAN_ON_START
|
||||||
|
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||||
|
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.12
|
||||||
|
|
||||||
|
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||||
|
|
||||||
|
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
|
||||||
|
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||||
|
- SCAN_ON_START unchanged, with non-root fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.11
|
||||||
|
|
||||||
|
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||||
|
|
||||||
|
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||||
|
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.10
|
||||||
|
|
||||||
|
Fix: index externally added files on startup; harden start.sh (#46)
|
||||||
|
|
||||||
|
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
|
||||||
|
- Execute scan as www-data; continue on failure so startup isn’t blocked
|
||||||
|
- Guard env reads for set -u; add umask 002 for consistent 775/664
|
||||||
|
- Make ServerName idempotent; avoid duplicate entries
|
||||||
|
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
|
||||||
|
|
||||||
|
No behavior change unless SCAN_ON_START=true.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/27/2025 v1.3.9
|
||||||
|
|
||||||
|
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||||
|
- New `scripts/scan_uploads.php` script to generate metadata for imported files and folders
|
||||||
|
- `SCAN_ON_START` environment variable to trigger automatic scanning on container startup
|
||||||
|
- Documentation for configuring CIFS share mounting and scanning
|
||||||
|
|
||||||
|
- Clipboard Paste Upload Support (single image):
|
||||||
|
- Users can now paste images directly into the FileRise web interface.
|
||||||
|
- Pasted images are renamed to `image<TIMESTAMP>.png` and added to the upload queue using the existing drag-and-drop logic.
|
||||||
|
- Implemented using a `.isClipboard` flag and a delayed UI cleanup inside `xhr.addEventListener("load", ...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/26/2025
|
||||||
|
|
||||||
|
- Updated `REGEX_FOLDER_NAME` in `config.php` to forbids < > : " | ? * characters in folder names.
|
||||||
|
- Ensures the whole name can’t end in a space or period.
|
||||||
|
- Blocks Windows device names.
|
||||||
|
|
||||||
|
- Updated `FolderController.php` when `createFolder` issues invalid folder name to return `http_response_code(400);`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/23/2025 v1.3.8
|
||||||
|
|
||||||
|
- **Folder-strip context menu**
|
||||||
|
- Enabled right-click on items in the new folder strip (above file list) to open the same “Create / Rename / Share / Delete Folder” menu as in the main folder tree.
|
||||||
|
- Bound `contextmenu` event on each `.folder-item` in `loadFileList` to:
|
||||||
|
- Prevent the default browser menu
|
||||||
|
- Highlight the clicked folder-strip item
|
||||||
|
- Invoke `showFolderManagerContextMenu` with menu entries:
|
||||||
|
- Create Folder
|
||||||
|
- Rename Folder
|
||||||
|
- Share Folder (passes the strip’s `data-folder` value)
|
||||||
|
- Delete Folder
|
||||||
|
- Ensured menu actions are wrapped in arrow functions (`() => …`) so they fire only on menu-item click, not on render.
|
||||||
|
|
||||||
|
- Refactored folder-strip injection in `fileListView.js` to:
|
||||||
|
- Mark each strip item as `draggable="true"` (for drag-and-drop)
|
||||||
|
- Add `el.addEventListener("contextmenu", …)` alongside existing click/drag handlers
|
||||||
|
- Clean up global click listener for hiding the context menu
|
||||||
|
|
||||||
|
- Prevented premature invocation of `openFolderShareModal` by switching to `action: () => openFolderShareModal(dest)` instead of calling it directly.
|
||||||
|
|
||||||
|
- **Create File/Folder dropdown**
|
||||||
|
- Replaced standalone “Create File” button with a combined dropdown button in the actions toolbar.
|
||||||
|
- New markup
|
||||||
|
- Wired up JS handlers in `fileActions.js`:
|
||||||
|
- `#createFileOption` → `openCreateFileModal()`
|
||||||
|
- `#createFolderOption` → `document.getElementById('createFolderModal').style.display = 'block'`
|
||||||
|
- Toggled `.dropdown-menu` visibility on button click, and closed on outside click.
|
||||||
|
- Applied dark-mode support: dropdown background and text colors switch with `.dark-mode` class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/22/2025 v1.3.7
|
||||||
|
|
||||||
|
- `.folder-strip-container .folder-name` css added to center text below folder material icon.
|
||||||
|
- Override file share_url to always use current origin
|
||||||
|
- Update `fileList` css to keep file name wrapping tight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/21/2025
|
||||||
|
|
||||||
|
- **Drag & Drop to Folder Strip**
|
||||||
|
- Enabled dragging files from the file list directly onto the folder-strip items.
|
||||||
|
- Hooked up `folderDragOverHandler`, `folderDragLeaveHandler`, and `folderDropHandler` to `.folder-strip-container .folder-item`.
|
||||||
|
- On drop, files are moved via `/api/file/moveFiles.php` and the file list is refreshed.
|
||||||
|
|
||||||
|
- **Restore files from trash Toast Message**
|
||||||
|
- Changed the restore handlers so that the toast always reports the actual file(s) restored (e.g. “Restored file: foo.txt”) instead of “No trash record found.”
|
||||||
|
- Removed reliance on backend message payload and now generate the confirmation text client-side based on selected items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/20/2025 v1.3.6
|
||||||
|
|
||||||
|
- **domUtils.js**
|
||||||
|
- `updateFileActionButtons`
|
||||||
|
- Hide selection buttons (`Delete Files`, `Copy Files`, `Move Files` & `Download ZIP`) until file is selected.
|
||||||
|
- Hide `Extract ZIP` until selecting zip files
|
||||||
|
- Hide `Create File` button when file list items are selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/19/2025 v1.3.5
|
||||||
|
|
||||||
|
### Added Folder strip & Create File
|
||||||
|
|
||||||
|
- **Folder strip in file list**
|
||||||
|
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
|
||||||
|
- Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`.
|
||||||
|
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
|
||||||
|
- Clicking a folder in the strip updates:
|
||||||
|
- the breadcrumb (via `updateBreadcrumbTitle`)
|
||||||
|
- the tree selection highlight
|
||||||
|
- reloads `loadFileList` for the chosen folder.
|
||||||
|
|
||||||
|
- **Create File feature**
|
||||||
|
- New “Create New File” button added to the file-actions toolbar and context menu.
|
||||||
|
- New endpoint `public/api/file/createFile.php` (handled by `FileController`/`FileModel`):
|
||||||
|
- Creates an empty file if it doesn’t already exist.
|
||||||
|
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
|
||||||
|
- `fileActions.js`:
|
||||||
|
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
|
||||||
|
- Added translations for `create_new_file` and `newfile_placeholder`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changees 5/15/2025
|
||||||
|
|
||||||
|
### Drag‐and‐Drop Upload extended to File List
|
||||||
|
|
||||||
|
- **Forward file‐list drops**
|
||||||
|
Dropping files onto the file‐list area (`#fileListContainer`) now re‐dispatches the same `drop` event to the upload card’s drop zone (`#uploadDropArea`)
|
||||||
|
- **Visual feedback**
|
||||||
|
Added a `.drop-hover` class on `#fileListContainer` during drag‐over for a dashed‐border + light‐background hover state to indicate it accepts file drops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/14/2025 v1.3.4
|
||||||
|
|
||||||
|
### 1. Button Grouping (Bootstrap)
|
||||||
|
|
||||||
|
- Converted individual action buttons (`download`, `edit`, `rename`, `share`) in both **table view** and **gallery view** into a single Bootstrap button group for a cleaner, more compact UI.
|
||||||
|
- Applied `btn-group` and `btn-sm` classes for consistent sizing and spacing.
|
||||||
|
|
||||||
|
### 2. Header Dropdown Replacement
|
||||||
|
|
||||||
|
- Replaced the standalone “User Panel” icon button with a **dropdown wrapper** (`.user-dropdown`) in the header.
|
||||||
|
- Dropdown toggle now shows:
|
||||||
|
- **Profile picture** (if set) or the Material “account_circle” icon
|
||||||
|
- **Username** text (between avatar and caret)
|
||||||
|
- Down-arrow caret span.
|
||||||
|
|
||||||
|
### 3. Menu Items Moved to Dropdown
|
||||||
|
|
||||||
|
- Moved previously standalone header buttons into the dropdown menu:
|
||||||
|
- **User Panel** opens the modal
|
||||||
|
- **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net`
|
||||||
|
- **API Docs** calls `openApiModal()`
|
||||||
|
- **Logout** calls `triggerLogout()`
|
||||||
|
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
|
||||||
|
|
||||||
|
### 4. Profile Picture Support
|
||||||
|
|
||||||
|
- Added a new `/api/profile/uploadPicture.php` endpoint + `UserController::uploadPicture()` + corresponding `UserModel::setProfilePicture()`.
|
||||||
|
- On **Open User Panel**, display:
|
||||||
|
- Default avatar if none set
|
||||||
|
- Current profile picture if available
|
||||||
|
- In the **User Panel** modal:
|
||||||
|
- Stylish “edit” overlay icon on the avatar to launch file picker
|
||||||
|
- Auto-upload on file selection (no “Save” button click needed)
|
||||||
|
- Preview updates immediately and header avatar refreshes live
|
||||||
|
- Persisted in `users.txt` and re-fetched via `getCurrentUser.php`
|
||||||
|
|
||||||
|
### 5. API Docs & Logout Relocation
|
||||||
|
|
||||||
|
- Removed API Docs from User Panel
|
||||||
|
- Removed “Logout” buttons from the header toolbar.
|
||||||
|
- Both are now menu entries in the **User Dropdown**.
|
||||||
|
|
||||||
|
### 6. Admin Panel Conditional
|
||||||
|
|
||||||
|
- The **Admin Panel** button was:
|
||||||
|
- Kept in the dropdown only when `data.isAdmin`
|
||||||
|
- Removed entirely elsewhere.
|
||||||
|
|
||||||
|
### 7. Utility & Styling Tweaks
|
||||||
|
|
||||||
|
- Introduced a small `normalizePicUrl()` helper to strip stray colons and ensure a leading slash.
|
||||||
|
- Hidden the scrollbar in the User Panel modal via:
|
||||||
|
- Inline CSS (`scrollbar-width: none; -ms-overflow-style: none;`)
|
||||||
|
- Global/WebKit rule for `::-webkit-scrollbar { display: none; }`
|
||||||
|
- Made the User Panel modal fully responsive and vertically centered, with smooth dark-mode support.
|
||||||
|
|
||||||
|
### 8. File/List View & Gallery View Sliders
|
||||||
|
|
||||||
|
- **Unified “View‐Mode” Slider**
|
||||||
|
Added a single slider panel (`#viewSliderContainer`) in the file‐list actions toolbar that switches behavior based on the current view mode:
|
||||||
|
- **Table View**: shows a **Row Height** slider (min 31px, max 60px).
|
||||||
|
- Adjusts the CSS variable `--file-row-height` to resize all `<tr>` heights.
|
||||||
|
- Persists the chosen height in `localStorage`.
|
||||||
|
- **Gallery View**: shows a **Columns** slider (min 1, max 6).
|
||||||
|
- Updates the grid’s `grid-template-columns: repeat(N, 1fr)`.
|
||||||
|
- Persists the chosen column count in `localStorage`.
|
||||||
|
|
||||||
|
- **Injection Point**
|
||||||
|
The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes.
|
||||||
|
|
||||||
|
- **Live Updates**
|
||||||
|
Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full re‐render.
|
||||||
|
|
||||||
|
- **Styling & Alignment**
|
||||||
|
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
|
||||||
|
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
|
||||||
|
|
||||||
|
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
|
||||||
|
|
||||||
|
**openUserPanel**
|
||||||
|
|
||||||
|
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
|
||||||
|
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
|
||||||
|
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
|
||||||
|
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
|
||||||
|
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
|
||||||
|
|
||||||
|
**updateAuthenticatedUI**
|
||||||
|
|
||||||
|
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
|
||||||
|
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
|
||||||
|
- **Dropdown rebuild logic** tweaked to update the toggle’s innerHTML with both avatar and username on every call.
|
||||||
|
|
||||||
|
**UserModel::getUser**
|
||||||
|
|
||||||
|
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
|
||||||
|
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
|
||||||
|
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
|
||||||
|
|
||||||
|
### 10. setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
|
||||||
|
|
||||||
|
### 11. Fix duplicated Upload & Folder cards if they were added to header and page was refreshed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/8/2025
|
||||||
|
|
||||||
|
### Docker 🐳
|
||||||
|
|
||||||
|
- Ensure `/var/www/config` exists and is owned by `www-data` (chmod 750) so that `start.sh`’s `sed -i` updates to `config.php` work reliably
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 5/8/2025 v1.3.3
|
## Changes 5/8/2025 v1.3.3
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
|
|||||||
COPY --from=appsource /var/www /var/www
|
COPY --from=appsource /var/www /var/www
|
||||||
COPY --from=composer /app/vendor /var/www/vendor
|
COPY --from=composer /app/vendor /var/www/vendor
|
||||||
|
|
||||||
|
# ── ensure config/ is writable by www-data so sed -i can work ──
|
||||||
|
RUN mkdir -p /var/www/config \
|
||||||
|
&& chown -R www-data:www-data /var/www/config \
|
||||||
|
&& chmod 750 /var/www/config
|
||||||
|
|
||||||
# Secure permissions: code read-only, only data dirs writable
|
# Secure permissions: code read-only, only data dirs writable
|
||||||
RUN chown -R root:www-data /var/www && \
|
RUN chown -R root:www-data /var/www && \
|
||||||
find /var/www -type d -exec chmod 755 {} \; && \
|
find /var/www -type d -exec chmod 755 {} \; && \
|
||||||
|
|||||||
130
README.md
130
README.md
@@ -52,38 +52,61 @@ Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (
|
|||||||
|
|
||||||
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
|
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
|
||||||
|
|
||||||
### 1. Running with Docker (Recommended)
|
---
|
||||||
|
|
||||||
If you have Docker installed, you can get FileRise up and running in minutes:
|
### 1) Running with Docker (Recommended)
|
||||||
|
|
||||||
- **Pull the image from Docker Hub:**
|
#### Pull the image
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
docker pull error311/filerise-docker:latest
|
docker pull error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Run a container:**
|
#### Run a container
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
--name filerise \
|
||||||
-p 8080:80 \
|
-p 8080:80 \
|
||||||
-e TIMEZONE="America/New_York" \
|
-e TIMEZONE="America/New_York" \
|
||||||
|
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||||
-e SECURE="false" \
|
-e SECURE="false" \
|
||||||
|
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
|
||||||
|
-e PUID="1000" \
|
||||||
|
-e PGID="1000" \
|
||||||
|
-e CHOWN_ON_START="true" \
|
||||||
|
-e SCAN_ON_START="true" \
|
||||||
|
-e SHARE_URL="" \
|
||||||
-v ~/filerise/uploads:/var/www/uploads \
|
-v ~/filerise/uploads:/var/www/uploads \
|
||||||
-v ~/filerise/users:/var/www/users \
|
-v ~/filerise/users:/var/www/users \
|
||||||
-v ~/filerise/metadata:/var/www/metadata \
|
-v ~/filerise/metadata:/var/www/metadata \
|
||||||
--name filerise \
|
|
||||||
error311/filerise-docker:latest
|
error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start FileRise on port 8080. Visit `http://your-server-ip:8080` to access it. Environment variables shown above are optional – for instance, set `SECURE="true"` to enforce HTTPS (assuming you have SSL at proxy level) and adjust `TIMEZONE` as needed. The volume mounts ensure your files and user data persist outside the container.
|
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||||
|
|
||||||
- **Using Docker Compose:**
|
**Notes**
|
||||||
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
|
|
||||||
|
|
||||||
``` yaml
|
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
||||||
version: '3'
|
- `CHOWN_ON_START=true` is recommended on **first run** to normalize ownership of existing trees. Set to **false** later for faster restarts.
|
||||||
|
- `SCAN_ON_START=true` runs a one-time index of files added outside the UI so their metadata appears.
|
||||||
|
- `SHARE_URL` is optional; leave blank to auto-detect from the current host/scheme. You can set it to your site root (e.g., `https://files.example.com`) or directly to the full endpoint.
|
||||||
|
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
||||||
|
|
||||||
|
**Verify ownership mapping (optional)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it filerise id www-data
|
||||||
|
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Docker Compose
|
||||||
|
|
||||||
|
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3"
|
||||||
services:
|
services:
|
||||||
filerise:
|
filerise:
|
||||||
image: error311/filerise-docker:latest
|
image: error311/filerise-docker:latest
|
||||||
@@ -91,59 +114,88 @@ services:
|
|||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
TIMEZONE: "UTC"
|
TIMEZONE: "UTC"
|
||||||
|
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||||
TOTAL_UPLOAD_SIZE: "10G"
|
TOTAL_UPLOAD_SIZE: "10G"
|
||||||
SECURE: "false"
|
SECURE: "false"
|
||||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||||
|
# Ownership & indexing
|
||||||
|
PUID: "1000" # Unraid users often use 99
|
||||||
|
PGID: "1000" # Unraid users often use 100
|
||||||
|
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
||||||
|
SCAN_ON_START: "true" # index files added outside the UI at boot
|
||||||
|
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
||||||
|
SHARE_URL: ""
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/var/www/uploads
|
- ./uploads:/var/www/uploads
|
||||||
- ./users:/var/www/users
|
- ./users:/var/www/users
|
||||||
- ./metadata:/var/www/metadata
|
- ./metadata:/var/www/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP). The above example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “remember me” tokens) – be sure to change it to a random string for security.
|
FileRise will be accessible at `http://localhost:8080` (or your server’s IP).
|
||||||
|
The example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “Remember Me” tokens)—change it to a strong random string.
|
||||||
|
|
||||||
**First-time Setup:** On first launch, FileRise will detect no users and prompt you to create an **Admin account**. Choose your admin username & password, and you’re in! You can then head to the **User Management** section to add additional users if needed.
|
**First-time Setup**
|
||||||
|
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. After logging in, use **User Management** to add more users.
|
||||||
|
|
||||||
### 2. Manual Installation (PHP/Apache)
|
---
|
||||||
|
|
||||||
|
### 2) Manual Installation (PHP/Apache)
|
||||||
|
|
||||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
||||||
|
|
||||||
- **Requirements:** PHP 8.3 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
|
**Requirements**
|
||||||
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
|
|
||||||
|
|
||||||
``` bash
|
- PHP **8.3+**
|
||||||
git clone https://github.com/error311/FileRise.git
|
- Apache (mod_php) or another web server configured for PHP
|
||||||
|
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
|
||||||
|
|
||||||
|
**Download Files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/error311/FileRise.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Place the files into your web server’s directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||||
|
|
||||||
- **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
|
**Composer (if applicable)**
|
||||||
|
If you use optional features requiring Composer libraries, run:
|
||||||
|
|
||||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
``` bash
|
**Folders & Permissions**
|
||||||
|
|
||||||
|
```bash
|
||||||
mkdir -p uploads users metadata
|
mkdir -p uploads users metadata
|
||||||
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate user
|
chown -R www-data:www-data uploads users metadata # use your web user
|
||||||
chmod -R 775 uploads users metadata
|
chmod -R 775 uploads users metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
The uploads/ folder is where files go, users/ stores the user credentials file, and metadata/ holds metadata like tags and share links.
|
- `uploads/`: actual files
|
||||||
|
- `users/`: credentials & token storage
|
||||||
|
- `metadata/`: file metadata (tags, share links, etc.)
|
||||||
|
|
||||||
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
|
**Configuration**
|
||||||
|
|
||||||
- `BASE_URL` – the URL where you will access FileRise (e.g., `“https://files.mydomain.com/”`). This is used for generating share links.
|
Open `config.php` and consider:
|
||||||
|
|
||||||
- `TIMEZONE` and `DATE_TIME_FORMAT` – match your locale (for correct timestamps).
|
|
||||||
|
|
||||||
- `TOTAL_UPLOAD_SIZE` – max aggregate upload size (default 5G). Also adjust PHP’s `upload_max_filesize` and `post_max_size` to at least this value (the Docker start script auto-adjusts PHP limits).
|
|
||||||
|
|
||||||
- `PERSISTENT_TOKENS_KEY` – set a unique secret if you use “Remember Me” logins, to encrypt the tokens.
|
|
||||||
|
|
||||||
- Other settings like `UPLOAD_DIR`, `USERS_FILE` etc. generally don’t need changes unless you move those folders. Defaults are set for the directories mentioned above.
|
|
||||||
|
|
||||||
- **Web Server Config:** If using Apache, ensure `.htaccess` files are allowed or manually add the rules from `.htaccess` to your Apache config – these disable directory listings and prevent access to certain files. For Nginx or others, you’ll need to replicate those protections (see Wiki: [Nginx Setup for examples](https://github.com/error311/FileRise/wiki/Nginx-Setup)). Also enable mod_rewrite if not already, as FileRise may use pretty URLs for share links.
|
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||||
|
- `TOTAL_UPLOAD_SIZE` (also ensure your PHP `upload_max_filesize` & `post_max_size` meet/exceed this).
|
||||||
|
- `PERSISTENT_TOKENS_KEY` set to a unique secret if using “Remember Me”.
|
||||||
|
|
||||||
Now navigate to the FileRise URL in your browser. On first load, you’ll be prompted to create the Admin user (same as Docker setup). After that, the application is ready to use!
|
**Share links base URL**
|
||||||
|
|
||||||
|
- You can set **`SHARE_URL`** via your web server environment variables (preferred),
|
||||||
|
**or** keep using `BASE_URL` in `config.php` as a fallback for manual installs.
|
||||||
|
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||||
|
|
||||||
|
**Web Server Config**
|
||||||
|
|
||||||
|
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||||
|
- Nginx/other: replicate the basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||||
|
|
||||||
|
Now browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,7 +270,7 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
|||||||
|
|
||||||
## Community and Support
|
## Community and Support
|
||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1jl01pi/introducing_filerise_a_modern_selfhosted_file/) – (Announcement and user feedback thread).
|
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
||||||
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
||||||
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.
|
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
|||||||
define('TIMEZONE', 'America/New_York');
|
define('TIMEZONE', 'America/New_York');
|
||||||
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
||||||
define('TOTAL_UPLOAD_SIZE','5G');
|
define('TOTAL_UPLOAD_SIZE','5G');
|
||||||
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
|
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
|
||||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||||
@@ -196,13 +196,21 @@ if (AUTH_BYPASS) {
|
|||||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Share URL fallback
|
|
||||||
|
// Share URL fallback (keep BASE_URL behavior)
|
||||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||||
|
|
||||||
|
// Detect scheme correctly (works behind proxies too)
|
||||||
|
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
|
||||||
|
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
|
||||||
|
);
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
|
||||||
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||||
$defaultShare = isset($_SERVER['HTTP_HOST'])
|
$defaultShare = "{$proto}://{$host}/api/file/share.php";
|
||||||
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
|
|
||||||
: "http://localhost/api/file/share.php";
|
|
||||||
} else {
|
} else {
|
||||||
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final: env var wins, else fallback
|
||||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||||
15
public/api/file/createFile.php
Normal file
15
public/api/file/createFile.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/createFile.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fc = new FileController();
|
||||||
|
$fc->createFile();
|
||||||
15
public/api/profile/getCurrentUser.php
Normal file
15
public/api/profile/getCurrentUser.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $_SESSION['username'];
|
||||||
|
$data = UserModel::getUser($user);
|
||||||
|
echo json_encode($data);
|
||||||
17
public/api/profile/uploadPicture.php
Normal file
17
public/api/profile/uploadPicture.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
// Always JSON, even on PHP notices
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userController = new UserController();
|
||||||
|
$userController->uploadPicture();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
BIN
public/assets/default-avatar.png
Normal file
BIN
public/assets/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -134,17 +134,27 @@ body.dark-mode header {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-buttons button:not(#userDropdownToggle) {
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#userDropdownToggle {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.header-buttons button:hover {
|
.header-buttons button:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -838,6 +848,27 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
|||||||
background-color: #00796B;
|
background-color: #00796B;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#createBtn {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dropdown-menu {
|
||||||
|
background-color: #2c2c2c !important;
|
||||||
|
border-color: #444 !important;
|
||||||
|
color: #e0e0e0!important;
|
||||||
|
}
|
||||||
|
body.dark-mode .dropdown-menu .dropdown-item {
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
body.dark-mode .dropdown-item:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
#fileList button.edit-btn {
|
#fileList button.edit-btn {
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -955,6 +986,29 @@ body.dark-mode #fileList table tr {
|
|||||||
padding: 8px 10px !important;
|
padding: 8px 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--file-row-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody tr {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: var(--file-row-height) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody td:not(.file-name-cell) {
|
||||||
|
height: var(--file-row-height) !important;
|
||||||
|
line-height: var(--file-row-height) !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody td.file-name-cell {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2em !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
HEADINGS & FORM LABELS
|
HEADINGS & FORM LABELS
|
||||||
@@ -1328,26 +1382,6 @@ body.dark-mode .image-preview-modal-content {
|
|||||||
border-color: #444;
|
border-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn,
|
|
||||||
.download-btn,
|
|
||||||
.rename-btn,
|
|
||||||
.share-btn,
|
|
||||||
.edit-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 0px;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -2102,13 +2136,23 @@ body.dark-mode .header-drop-zone.drag-active {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
#fileSummary {
|
#fileSummary,
|
||||||
float: none !important;
|
#rowHeightSliderContainer,
|
||||||
margin: 0 auto !important;
|
#viewSliderContainer {
|
||||||
text-align: center !important;
|
float: none !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
text-align: center !important;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#viewSliderContainer label,
|
||||||
|
#viewSliderContainer span {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode #fileSummary {
|
body.dark-mode #fileSummary {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -2165,4 +2209,100 @@ body.dark-mode #searchIcon .material-icons {
|
|||||||
body.dark-mode .btn-icon:hover,
|
body.dark-mode .btn-icon:hover,
|
||||||
body.dark-mode .btn-icon:focus {
|
body.dark-mode .btn-icon:focus {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--bs-body-bg, #fff);
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu .item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.user-dropdown .user-menu .item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .dropdown-caret {
|
||||||
|
border-top: 5px solid currentColor;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu {
|
||||||
|
background: #2c2c2c;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu .item {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu .item:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .dropdown-username {
|
||||||
|
margin: 0 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 80px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item i.material-icons {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-name {
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 80px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item i.material-icons {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
@@ -11,13 +11,18 @@
|
|||||||
<meta name="share-url" content="">
|
<meta name="share-url" content="">
|
||||||
<style>
|
<style>
|
||||||
/* hide the app shell until JS says otherwise */
|
/* hide the app shell until JS says otherwise */
|
||||||
.main-wrapper { display: none; }
|
.main-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* full-screen white overlay while we check auth */
|
/* full-screen white overlay while we check auth */
|
||||||
#loadingOverlay {
|
#loadingOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0;
|
||||||
background: var(--bg-color,#fff);
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-color, #fff);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,9 +140,6 @@
|
|||||||
<!-- Your header drop zone -->
|
<!-- Your header drop zone -->
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="logoutBtn" data-i18n-title="logout">
|
|
||||||
<i class="material-icons">exit_to_app</i>
|
|
||||||
</button>
|
|
||||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||||
<i class="material-icons">vpn_key</i>
|
<i class="material-icons">vpn_key</i>
|
||||||
</button>
|
</button>
|
||||||
@@ -387,8 +389,55 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||||
data-i18n-key="download_zip">Download ZIP</button>
|
data-i18n-key="download_zip">Download ZIP</button>
|
||||||
<button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip"
|
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
|
||||||
data-i18n-key="extract_zip_button">Extract Zip</button>
|
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||||
|
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||||
|
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
||||||
|
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
id="createMenu"
|
||||||
|
class="dropdown-menu"
|
||||||
|
style="
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 140px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
${t('create_file')}
|
||||||
|
</li>
|
||||||
|
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
${t('create_folder')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Create File Modal -->
|
||||||
|
<div id="createFileModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="createFileNameInput"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Enter filename…"
|
||||||
|
data-i18n-placeholder="newfile_placeholder"
|
||||||
|
/>
|
||||||
|
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
||||||
|
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="downloadZipModal" class="modal" style="display:none;">
|
<div id="downloadZipModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
||||||
@@ -443,8 +492,7 @@
|
|||||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||||
<span id="closeChangePasswordModal"
|
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||||
class="editor-close-btn">×</span>
|
|
||||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||||
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||||
@@ -462,15 +510,15 @@
|
|||||||
<form id="addUserForm">
|
<form id="addUserForm">
|
||||||
<label for="newUsername" data-i18n-key="username">Username:</label>
|
<label for="newUsername" data-i18n-key="username">Username:</label>
|
||||||
<input type="text" id="newUsername" class="form-control" required />
|
<input type="text" id="newUsername" class="form-control" required />
|
||||||
|
|
||||||
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
||||||
<input type="password" id="addUserPassword" class="form-control" required />
|
<input type="password" id="addUserPassword" class="form-control" required />
|
||||||
|
|
||||||
<div id="adminCheckboxContainer">
|
<div id="adminCheckboxContainer">
|
||||||
<input type="checkbox" id="isAdmin" />
|
<input type="checkbox" id="isAdmin" />
|
||||||
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-container">
|
<div class="button-container">
|
||||||
<!-- Cancel stays type="button" -->
|
<!-- Cancel stays type="button" -->
|
||||||
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
|
|
||||||
const version = "v1.3.3";
|
const version = "v1.3.15";
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||||
|
|
||||||
// ————— Inject updated styles —————
|
// ————— Inject updated styles —————
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
openUserPanel,
|
openUserPanel,
|
||||||
openTOTPModal,
|
openTOTPModal,
|
||||||
closeTOTPModal,
|
closeTOTPModal,
|
||||||
setLastLoginData
|
setLastLoginData,
|
||||||
|
openApiModal
|
||||||
} from './authModals.js';
|
} from './authModals.js';
|
||||||
import { openAdminPanel } from './adminPanel.js';
|
import { openAdminPanel } from './adminPanel.js';
|
||||||
import { initializeApp } from './main.js';
|
import { initializeApp, triggerLogout } from './main.js';
|
||||||
|
|
||||||
// Production OIDC configuration (override via API as needed)
|
// Production OIDC configuration (override via API as needed)
|
||||||
const currentOIDCConfig = {
|
const currentOIDCConfig = {
|
||||||
@@ -154,7 +155,7 @@ function updateLoginOptionsUIFromStorage() {
|
|||||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
|
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
|
||||||
authBypass: localStorage.getItem("authBypass") === "true"
|
authBypass: localStorage.getItem("authBypass") === "true"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,21 +200,48 @@ function insertAfter(newNode, referenceNode) {
|
|||||||
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAuthenticatedUI(data) {
|
async function fetchProfilePicture() {
|
||||||
document.getElementById('loadingOverlay').remove();
|
try {
|
||||||
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const info = await res.json();
|
||||||
|
let pic = info.profile_picture || '';
|
||||||
|
// --- take only what's after the *last* colon ---
|
||||||
|
const parts = pic.split(':');
|
||||||
|
pic = parts[parts.length - 1] || '';
|
||||||
|
// strip any stray leading colons
|
||||||
|
pic = pic.replace(/^:+/, '');
|
||||||
|
// ensure exactly one leading slash
|
||||||
|
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||||
|
return pic;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchProfilePicture failed:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
export async function updateAuthenticatedUI(data) {
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
// Save latest auth data for later reuse
|
||||||
document.getElementById('loginForm').style.display = 'none';
|
window.__lastAuthData = data;
|
||||||
|
|
||||||
|
// 1) Remove loading overlay safely
|
||||||
|
const loading = document.getElementById('loadingOverlay');
|
||||||
|
if (loading) loading.remove();
|
||||||
|
|
||||||
|
// 2) Show main UI
|
||||||
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
toggleVisibility("loginForm", false);
|
toggleVisibility("loginForm", false);
|
||||||
toggleVisibility("mainOperations", true);
|
toggleVisibility("mainOperations", true);
|
||||||
toggleVisibility("uploadFileForm", true);
|
toggleVisibility("uploadFileForm", true);
|
||||||
toggleVisibility("fileListContainer", true);
|
toggleVisibility("fileListContainer", true);
|
||||||
//attachEnterKeyListener("addUserModal", "saveUserBtn");
|
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
|
||||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||||
|
|
||||||
|
// 3) Persist auth flags (unchanged)
|
||||||
if (typeof data.totp_enabled !== "undefined") {
|
if (typeof data.totp_enabled !== "undefined") {
|
||||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
@@ -221,64 +249,156 @@ function updateAuthenticatedUI(data) {
|
|||||||
localStorage.setItem("username", data.username);
|
localStorage.setItem("username", data.username);
|
||||||
}
|
}
|
||||||
if (typeof data.folderOnly !== "undefined") {
|
if (typeof data.folderOnly !== "undefined") {
|
||||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||||
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||||
|
const profilePicUrl = await fetchProfilePicture();
|
||||||
|
localStorage.setItem("profilePicUrl", profilePicUrl);
|
||||||
|
|
||||||
|
// 5) Build / update header buttons
|
||||||
const headerButtons = document.querySelector(".header-buttons");
|
const headerButtons = document.querySelector(".header-buttons");
|
||||||
const firstButton = headerButtons.firstElementChild;
|
const firstButton = headerButtons.firstElementChild;
|
||||||
|
|
||||||
|
// a) restore-from-trash for admins
|
||||||
if (data.isAdmin) {
|
if (data.isAdmin) {
|
||||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
let r = document.getElementById("restoreFilesBtn");
|
||||||
if (!restoreBtn) {
|
if (!r) {
|
||||||
restoreBtn = document.createElement("button");
|
r = document.createElement("button");
|
||||||
restoreBtn.id = "restoreFilesBtn";
|
r.id = "restoreFilesBtn";
|
||||||
restoreBtn.classList.add("btn", "btn-warning");
|
r.classList.add("btn","btn-warning");
|
||||||
restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete");
|
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||||
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||||
if (firstButton) insertAfter(restoreBtn, firstButton);
|
if (firstButton) insertAfter(r, firstButton);
|
||||||
else headerButtons.appendChild(restoreBtn);
|
else headerButtons.appendChild(r);
|
||||||
}
|
|
||||||
restoreBtn.style.display = "block";
|
|
||||||
|
|
||||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (!adminPanelBtn) {
|
|
||||||
adminPanelBtn = document.createElement("button");
|
|
||||||
adminPanelBtn.id = "adminPanelBtn";
|
|
||||||
adminPanelBtn.classList.add("btn", "btn-info");
|
|
||||||
adminPanelBtn.setAttribute("data-i18n-title", "admin_panel");
|
|
||||||
adminPanelBtn.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
|
||||||
insertAfter(adminPanelBtn, restoreBtn);
|
|
||||||
adminPanelBtn.addEventListener("click", openAdminPanel);
|
|
||||||
} else {
|
|
||||||
adminPanelBtn.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
r.style.display = "block";
|
||||||
} else {
|
} else {
|
||||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
const r = document.getElementById("restoreFilesBtn");
|
||||||
if (restoreBtn) restoreBtn.style.display = "none";
|
if (r) r.style.display = "none";
|
||||||
const adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.hostname !== "demo.filerise.net") {
|
// b) admin panel button only on demo.filerise.net
|
||||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||||
if (!userPanelBtn) {
|
let a = document.getElementById("adminPanelBtn");
|
||||||
userPanelBtn = document.createElement("button");
|
if (!a) {
|
||||||
userPanelBtn.id = "userPanelBtn";
|
a = document.createElement("button");
|
||||||
userPanelBtn.classList.add("btn", "btn-user");
|
a.id = "adminPanelBtn";
|
||||||
userPanelBtn.setAttribute("data-i18n-title", "user_panel");
|
a.classList.add("btn","btn-info");
|
||||||
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
|
a.setAttribute("data-i18n-title","admin_panel");
|
||||||
|
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||||
|
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||||
|
a.addEventListener("click", openAdminPanel);
|
||||||
|
}
|
||||||
|
a.style.display = "block";
|
||||||
|
} else {
|
||||||
|
const a = document.getElementById("adminPanelBtn");
|
||||||
|
if (a) a.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// c) user dropdown on non-demo
|
||||||
|
if (window.location.hostname !== "demo.filerise.net") {
|
||||||
|
let dd = document.getElementById("userDropdown");
|
||||||
|
|
||||||
|
// choose icon *or* img
|
||||||
|
const avatarHTML = profilePicUrl
|
||||||
|
? `<img src="${profilePicUrl}" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;">`
|
||||||
|
: `<i class="material-icons">account_circle</i>`;
|
||||||
|
|
||||||
|
// fallback username if missing
|
||||||
|
const usernameText = data.username
|
||||||
|
|| localStorage.getItem("username")
|
||||||
|
|| "";
|
||||||
|
|
||||||
|
if (!dd) {
|
||||||
|
dd = document.createElement("div");
|
||||||
|
dd.id = "userDropdown";
|
||||||
|
dd.classList.add("user-dropdown");
|
||||||
|
|
||||||
|
// toggle button
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.id = "userDropdownToggle";
|
||||||
|
toggle.classList.add("btn","btn-user");
|
||||||
|
toggle.setAttribute("title", t("user_settings"));
|
||||||
|
toggle.innerHTML = `
|
||||||
|
${avatarHTML}
|
||||||
|
<span class="dropdown-username">${usernameText}</span>
|
||||||
|
<span class="dropdown-caret"></span>
|
||||||
|
`;
|
||||||
|
dd.append(toggle);
|
||||||
|
|
||||||
|
// menu
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.classList.add("user-menu");
|
||||||
|
menu.innerHTML = `
|
||||||
|
<div class="item" id="menuUserPanel">
|
||||||
|
<i class="material-icons folder-icon">person</i> ${t("user_panel")}
|
||||||
|
</div>
|
||||||
|
${data.isAdmin ? `
|
||||||
|
<div class="item" id="menuAdminPanel">
|
||||||
|
<i class="material-icons folder-icon">admin_panel_settings</i> ${t("admin_panel")}
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="item" id="menuApiDocs">
|
||||||
|
<i class="material-icons folder-icon">description</i> ${t("api_docs")}
|
||||||
|
</div>
|
||||||
|
<div class="item" id="menuLogout">
|
||||||
|
<i class="material-icons folder-icon">logout</i> ${t("logout")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dd.append(menu);
|
||||||
|
|
||||||
|
// insert
|
||||||
|
const dm = document.getElementById("darkModeToggle");
|
||||||
|
if (dm) insertAfter(dd, dm);
|
||||||
|
else if (firstButton) insertAfter(dd, firstButton);
|
||||||
|
else headerButtons.appendChild(dd);
|
||||||
|
|
||||||
|
// open/close
|
||||||
|
toggle.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.classList.toggle("show");
|
||||||
|
});
|
||||||
|
document.addEventListener("click", () => menu.classList.remove("show"));
|
||||||
|
|
||||||
|
// actions
|
||||||
|
document.getElementById("menuUserPanel")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openUserPanel();
|
||||||
|
});
|
||||||
|
if (data.isAdmin) {
|
||||||
|
document.getElementById("menuAdminPanel")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openAdminPanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById("menuApiDocs")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openApiModal();
|
||||||
|
});
|
||||||
|
document.getElementById("menuLogout")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
triggerLogout();
|
||||||
|
});
|
||||||
|
|
||||||
const adminBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminBtn) insertAfter(userPanelBtn, adminBtn);
|
|
||||||
else if (firstButton) insertAfter(userPanelBtn, firstButton);
|
|
||||||
else headerButtons.appendChild(userPanelBtn);
|
|
||||||
userPanelBtn.addEventListener("click", openUserPanel);
|
|
||||||
} else {
|
} else {
|
||||||
userPanelBtn.style.display = "block";
|
// update avatar & username only
|
||||||
|
const tog = dd.querySelector("#userDropdownToggle");
|
||||||
|
tog.innerHTML = `
|
||||||
|
${avatarHTML}
|
||||||
|
<span class="dropdown-username">${usernameText}</span>
|
||||||
|
<span class="dropdown-caret"></span>
|
||||||
|
`;
|
||||||
|
dd.style.display = "inline-block";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6) Finalize
|
||||||
initializeApp();
|
initializeApp();
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
updateItemsPerPageSelect();
|
updateItemsPerPageSelect();
|
||||||
@@ -289,7 +409,8 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
return sendRequest("/api/auth/checkAuth.php")
|
return sendRequest("/api/auth/checkAuth.php")
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
@@ -322,13 +443,14 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
updateAuthenticatedUI(data);
|
updateAuthenticatedUI(data);
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
document.getElementById('loginForm').style.display = '';
|
document.getElementById('loginForm').style.display = '';
|
||||||
if (showLoginToast) showToast("Please log in to continue.");
|
if (showLoginToast) showToast("Please log in to continue.");
|
||||||
toggleVisibility("loginForm", ! (localStorage.getItem("authBypass")==="true"));
|
toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true"));
|
||||||
toggleVisibility("mainOperations", false);
|
toggleVisibility("mainOperations", false);
|
||||||
toggleVisibility("uploadFileForm", false);
|
toggleVisibility("uploadFileForm", false);
|
||||||
toggleVisibility("fileListContainer", false);
|
toggleVisibility("fileListContainer", false);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||||||
|
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
export function setLastLoginData(data) {
|
export function setLastLoginData(data) {
|
||||||
@@ -60,14 +59,11 @@ export function openTOTPLoginModal() {
|
|||||||
const totpSection = document.getElementById("totpSection");
|
const totpSection = document.getElementById("totpSection");
|
||||||
const recoverySection = document.getElementById("recoverySection");
|
const recoverySection = document.getElementById("recoverySection");
|
||||||
const toggleLink = this;
|
const toggleLink = this;
|
||||||
|
|
||||||
if (recoverySection.style.display === "none") {
|
if (recoverySection.style.display === "none") {
|
||||||
// Switch to recovery
|
|
||||||
totpSection.style.display = "none";
|
totpSection.style.display = "none";
|
||||||
recoverySection.style.display = "block";
|
recoverySection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_totp_code_instead");
|
toggleLink.textContent = t("use_totp_code_instead");
|
||||||
} else {
|
} else {
|
||||||
// Switch back to TOTP
|
|
||||||
recoverySection.style.display = "none";
|
recoverySection.style.display = "none";
|
||||||
totpSection.style.display = "block";
|
totpSection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_recovery_code_instead");
|
toggleLink.textContent = t("use_recovery_code_instead");
|
||||||
@@ -93,7 +89,6 @@ export function openTOTPLoginModal() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(json => {
|
.then(json => {
|
||||||
if (json.status === "ok") {
|
if (json.status === "ok") {
|
||||||
// recovery succeeded → finalize login
|
|
||||||
window.location.href = "/index.html";
|
window.location.href = "/index.html";
|
||||||
} else {
|
} else {
|
||||||
showToast(json.message || t("recovery_code_verification_failed"));
|
showToast(json.message || t("recovery_code_verification_failed"));
|
||||||
@@ -107,17 +102,11 @@ export function openTOTPLoginModal() {
|
|||||||
// TOTP submission
|
// TOTP submission
|
||||||
const totpInput = document.getElementById("totpLoginInput");
|
const totpInput = document.getElementById("totpLoginInput");
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
|
|
||||||
totpInput.addEventListener("input", async function () {
|
totpInput.addEventListener("input", async function () {
|
||||||
const code = this.value.trim();
|
const code = this.value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) return;
|
||||||
|
|
||||||
return;
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
}
|
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
if (!tokenRes.ok) {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +133,6 @@ export function openTOTPLoginModal() {
|
|||||||
} else {
|
} else {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.value = "";
|
this.value = "";
|
||||||
totpLoginModal.style.display = "flex";
|
totpLoginModal.style.display = "flex";
|
||||||
this.focus();
|
this.focus();
|
||||||
@@ -160,153 +148,279 @@ export function openTOTPLoginModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUserPanel() {
|
/**
|
||||||
const username = localStorage.getItem("username") || "User";
|
* Fetch current user info (username, profile_picture, totp_enabled)
|
||||||
let userPanelModal = document.getElementById("userPanelModal");
|
*/
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
async function fetchCurrentUser() {
|
||||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
try {
|
||||||
const modalContentStyles = `
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
credentials: 'include'
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchCurrentUser failed:', e);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize any profile‐picture URL:
|
||||||
|
* - strip leading colons
|
||||||
|
* - ensure exactly one leading slash
|
||||||
|
*/
|
||||||
|
function normalizePicUrl(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
// take only what's after the last colon
|
||||||
|
const parts = raw.split(':');
|
||||||
|
let pic = parts[parts.length - 1];
|
||||||
|
// strip any stray colons
|
||||||
|
pic = pic.replace(/^:+/, '');
|
||||||
|
// ensure leading slash
|
||||||
|
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||||
|
return pic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUserPanel() {
|
||||||
|
// 1) load data
|
||||||
|
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
|
||||||
|
const raw = profile_picture;
|
||||||
|
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
|
||||||
|
|
||||||
|
// 2) dark‐mode helpers
|
||||||
|
const isDark = document.body.classList.contains('dark-mode');
|
||||||
|
const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)';
|
||||||
|
const contentStyle = `
|
||||||
|
background: ${isDark ? '#2c2c2c' : '#fff'};
|
||||||
|
color: ${isDark ? '#e0e0e0' : '#000'};
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 600px;
|
max-width: 600px; width:90%;
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow-y: auto;
|
overflow-y: auto; max-height: 500px;
|
||||||
overflow-x: hidden;
|
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||||||
max-height: 383px !important;
|
|
||||||
flex-shrink: 0 !important;
|
|
||||||
scrollbar-gutter: stable both-edges;
|
|
||||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: none;
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
`;
|
`;
|
||||||
const savedLanguage = localStorage.getItem("language") || "en";
|
|
||||||
|
|
||||||
if (!userPanelModal) {
|
// 3) create or reuse modal
|
||||||
userPanelModal = document.createElement("div");
|
let modal = document.getElementById('userPanelModal');
|
||||||
userPanelModal.id = "userPanelModal";
|
if (!modal) {
|
||||||
userPanelModal.style.cssText = `
|
// overlay
|
||||||
position: fixed;
|
modal = document.createElement('div');
|
||||||
top: 0; right: 0; bottom: 0; left: 0;
|
modal.id = 'userPanelModal';
|
||||||
background-color: ${overlayBackground};
|
Object.assign(modal.style, {
|
||||||
display: flex;
|
position: 'fixed',
|
||||||
justify-content: center;
|
top: '0',
|
||||||
align-items: center;
|
left: '0',
|
||||||
z-index: 1000;
|
right: '0',
|
||||||
overflow: hidden;
|
bottom: '0',
|
||||||
|
background: overlayBg,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: '1000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// content container
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'modal-content';
|
||||||
|
content.style.cssText = contentStyle;
|
||||||
|
|
||||||
|
// close button
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.id = 'closeUserPanel';
|
||||||
|
closeBtn.className = 'editor-close-btn';
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||||
|
content.appendChild(closeBtn);
|
||||||
|
|
||||||
|
// avatar + picker
|
||||||
|
const avatarWrapper = document.createElement('div');
|
||||||
|
avatarWrapper.style.cssText = 'text-align:center; margin-bottom:20px;';
|
||||||
|
const avatarInner = document.createElement('div');
|
||||||
|
avatarInner.style.cssText = 'position:relative; width:80px; height:80px; margin:0 auto;';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'profilePicPreview';
|
||||||
|
img.src = picUrl;
|
||||||
|
img.alt = 'Profile Picture';
|
||||||
|
img.style.cssText = 'width:100%; height:100%; border-radius:50%; object-fit:cover;';
|
||||||
|
avatarInner.appendChild(img);
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.htmlFor = 'profilePicInput';
|
||||||
|
label.style.cssText = `
|
||||||
|
position:absolute; bottom:0; right:0;
|
||||||
|
width:24px; height:24px;
|
||||||
|
background:rgba(0,0,0,0.6);
|
||||||
|
border-radius:50%; display:flex;
|
||||||
|
align-items:center; justify-content:center;
|
||||||
|
cursor:pointer;
|
||||||
`;
|
`;
|
||||||
userPanelModal.innerHTML = `
|
const editIcon = document.createElement('i');
|
||||||
<div class="modal-content user-panel-content" style="${modalContentStyles}">
|
editIcon.className = 'material-icons';
|
||||||
<span id="closeUserPanel" class="editor-close-btn">×</span>
|
editIcon.style.cssText = 'color:#fff; font-size:16px;';
|
||||||
<h3>${t("user_panel")} (${username})</h3>
|
editIcon.textContent = 'edit';
|
||||||
|
label.appendChild(editIcon);
|
||||||
|
avatarInner.appendChild(label);
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.id = 'profilePicInput';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
avatarInner.appendChild(fileInput);
|
||||||
|
avatarWrapper.appendChild(avatarInner);
|
||||||
|
content.appendChild(avatarWrapper);
|
||||||
|
|
||||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">
|
// title
|
||||||
${t("change_password")}
|
const title = document.createElement('h3');
|
||||||
</button>
|
title.style.cssText = 'text-align:center; margin-bottom:20px;';
|
||||||
|
title.textContent = `${t('user_panel')} (${username})`;
|
||||||
|
content.appendChild(title);
|
||||||
|
|
||||||
<fieldset style="margin-bottom: 15px;">
|
// change password btn
|
||||||
<legend>${t("totp_settings")}</legend>
|
const pwdBtn = document.createElement('button');
|
||||||
<div class="form-group">
|
pwdBtn.id = 'openChangePasswordModalBtn';
|
||||||
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
|
pwdBtn.className = 'btn btn-primary';
|
||||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
pwdBtn.style.marginBottom = '15px';
|
||||||
</div>
|
pwdBtn.textContent = t('change_password');
|
||||||
</fieldset>
|
pwdBtn.addEventListener('click', () => {
|
||||||
|
document.getElementById('changePasswordModal').style.display = 'block';
|
||||||
<fieldset style="margin-bottom: 15px;">
|
|
||||||
<legend>${t("language")}</legend>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="languageSelector">${t("select_language")}:</label>
|
|
||||||
<select id="languageSelector">
|
|
||||||
<option value="en">${t("english")}</option>
|
|
||||||
<option value="es">${t("spanish")}</option>
|
|
||||||
<option value="fr">${t("french")}</option>
|
|
||||||
<option value="de">${t("german")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- New API Docs link -->
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
|
|
||||||
${t("api_docs") || "API Docs"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(userPanelModal);
|
|
||||||
|
|
||||||
const apiModal = document.createElement("div");
|
|
||||||
apiModal.id = "apiModal";
|
|
||||||
apiModal.style.cssText = `
|
|
||||||
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
|
||||||
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// api.php
|
|
||||||
apiModal.innerHTML = `
|
|
||||||
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
|
||||||
<div class="editor-close-btn" id="closeApiModal">×</div>
|
|
||||||
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(apiModal);
|
|
||||||
|
|
||||||
document.getElementById("openApiModalBtn").addEventListener("click", () => {
|
|
||||||
apiModal.style.display = "flex";
|
|
||||||
});
|
|
||||||
document.getElementById("closeApiModal").addEventListener("click", () => {
|
|
||||||
apiModal.style.display = "none";
|
|
||||||
});
|
});
|
||||||
|
content.appendChild(pwdBtn);
|
||||||
|
|
||||||
// Handlers…
|
// TOTP fieldset
|
||||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
const totpFs = document.createElement('fieldset');
|
||||||
userPanelModal.style.display = "none";
|
totpFs.style.marginBottom = '15px';
|
||||||
});
|
const totpLegend = document.createElement('legend');
|
||||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
totpLegend.textContent = t('totp_settings');
|
||||||
document.getElementById("changePasswordModal").style.display = "block";
|
totpFs.appendChild(totpLegend);
|
||||||
});
|
const totpLabel = document.createElement('label');
|
||||||
|
totpLabel.style.cursor = 'pointer';
|
||||||
|
const totpCb = document.createElement('input');
|
||||||
// TOTP checkbox
|
totpCb.type = 'checkbox';
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
totpCb.id = 'userTOTPEnabled';
|
||||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
totpCb.style.verticalAlign = 'middle';
|
||||||
totpCheckbox.addEventListener("change", function () {
|
totpCb.checked = totp_enabled;
|
||||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
totpCb.addEventListener('change', async function () {
|
||||||
fetch("/api/updateUserPanel.php", {
|
const resp = await fetch('/api/updateUserPanel.php', {
|
||||||
method: "POST",
|
method: 'POST', credentials: 'include',
|
||||||
credentials: "include",
|
headers: {
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
body: JSON.stringify({ totp_enabled: this.checked })
|
body: JSON.stringify({ totp_enabled: this.checked })
|
||||||
})
|
});
|
||||||
.then(r => r.json())
|
const js = await resp.json();
|
||||||
.then(result => {
|
if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
|
||||||
if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error);
|
else if (this.checked) openTOTPModal();
|
||||||
else if (this.checked) openTOTPModal();
|
|
||||||
})
|
|
||||||
.catch(() => showToast(t("error_updating_totp_setting")));
|
|
||||||
});
|
});
|
||||||
|
totpLabel.appendChild(totpCb);
|
||||||
|
totpLabel.append(` ${t('enable_totp')}`);
|
||||||
|
totpFs.appendChild(totpLabel);
|
||||||
|
content.appendChild(totpFs);
|
||||||
|
|
||||||
// Language selector
|
// language fieldset
|
||||||
const languageSelector = document.getElementById("languageSelector");
|
const langFs = document.createElement('fieldset');
|
||||||
languageSelector.value = savedLanguage;
|
langFs.style.marginBottom = '15px';
|
||||||
languageSelector.addEventListener("change", function () {
|
const langLegend = document.createElement('legend');
|
||||||
localStorage.setItem("language", this.value);
|
langLegend.textContent = t('language');
|
||||||
|
langFs.appendChild(langLegend);
|
||||||
|
const langSel = document.createElement('select');
|
||||||
|
langSel.id = 'languageSelector';
|
||||||
|
langSel.className = 'form-select';
|
||||||
|
['en', 'es', 'fr', 'de'].forEach(code => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = code;
|
||||||
|
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
|
||||||
|
langSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
langSel.value = localStorage.getItem('language') || 'en';
|
||||||
|
langSel.addEventListener('change', function () {
|
||||||
|
localStorage.setItem('language', this.value);
|
||||||
setLocale(this.value);
|
setLocale(this.value);
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
});
|
});
|
||||||
|
langFs.appendChild(langSel);
|
||||||
|
content.appendChild(langFs);
|
||||||
|
|
||||||
|
// --- Display fieldset: “Show folders above files” ---
|
||||||
|
const dispFs = document.createElement('fieldset');
|
||||||
|
dispFs.style.marginBottom = '15px';
|
||||||
|
const dispLegend = document.createElement('legend');
|
||||||
|
dispLegend.textContent = t('display');
|
||||||
|
dispFs.appendChild(dispLegend);
|
||||||
|
const dispLabel = document.createElement('label');
|
||||||
|
dispLabel.style.cursor = 'pointer';
|
||||||
|
const dispCb = document.createElement('input');
|
||||||
|
dispCb.type = 'checkbox';
|
||||||
|
dispCb.id = 'showFoldersInList';
|
||||||
|
dispCb.style.verticalAlign = 'middle';
|
||||||
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
|
dispCb.checked = stored === null ? true : stored === 'true';
|
||||||
|
dispLabel.appendChild(dispCb);
|
||||||
|
dispLabel.append(` ${t('show_folders_above_files')}`);
|
||||||
|
dispFs.appendChild(dispLabel);
|
||||||
|
content.appendChild(dispFs);
|
||||||
|
|
||||||
|
dispCb.addEventListener('change', () => {
|
||||||
|
window.showFoldersInList = dispCb.checked;
|
||||||
|
localStorage.setItem('showFoldersInList', dispCb.checked);
|
||||||
|
// re‐load the entire file list (and strip) in one go:
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
// wire up image‐input change
|
||||||
|
fileInput.addEventListener('change', async function () {
|
||||||
|
const f = this.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
// preview immediately
|
||||||
|
// #nosec
|
||||||
|
img.src = URL.createObjectURL(f);
|
||||||
|
const blobUrl = URL.createObjectURL(f);
|
||||||
|
// use setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
|
||||||
|
img.setAttribute('src', encodeURI(blobUrl));
|
||||||
|
// upload
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('profile_picture', f);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/uploadPicture.php', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const js = JSON.parse(text || '{}');
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast(js.error || t('error_updating_picture'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newUrl = normalizePicUrl(js.url);
|
||||||
|
img.src = newUrl;
|
||||||
|
localStorage.setItem('profilePicUrl', newUrl);
|
||||||
|
updateAuthenticatedUI(window.__lastAuthData || {});
|
||||||
|
showToast(t('profile_picture_updated'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast(t('error_updating_picture'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// finalize
|
||||||
|
modal.appendChild(content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
} else {
|
} else {
|
||||||
// Update colors if already exists
|
// reuse on reopen
|
||||||
userPanelModal.style.backgroundColor = overlayBackground;
|
Object.assign(modal.style, { background: overlayBg });
|
||||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
const content = modal.querySelector('.modal-content');
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
content.style.cssText = contentStyle;
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
|
||||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
||||||
|
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
||||||
|
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
userPanelModal.style.display = "flex";
|
// show
|
||||||
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRecoveryCodeModal(recoveryCode) {
|
function showRecoveryCodeModal(recoveryCode) {
|
||||||
@@ -314,26 +428,21 @@ function showRecoveryCodeModal(recoveryCode) {
|
|||||||
recoveryModal.id = "recoveryModal";
|
recoveryModal.id = "recoveryModal";
|
||||||
recoveryModal.style.cssText = `
|
recoveryModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0; left: 0;
|
||||||
left: 0;
|
width: 100vw; height: 100vh;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: rgba(0,0,0,0.3);
|
background-color: rgba(0,0,0,0.3);
|
||||||
display: flex;
|
display: flex; justify-content: center; align-items: center;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3200;
|
z-index: 3200;
|
||||||
`;
|
`;
|
||||||
recoveryModal.innerHTML = `
|
recoveryModal.innerHTML = `
|
||||||
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
|
<div style="background:#fff; color:#000; padding:20px; max-width:400px; width:90%; border-radius:8px; text-align:center;">
|
||||||
<h3>${t("your_recovery_code")}</h3>
|
<h3>${t("your_recovery_code")}</h3>
|
||||||
<p>${t("please_save_recovery_code")}</p>
|
<p>${t("please_save_recovery_code")}</p>
|
||||||
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
|
<code style="display:block; margin:10px 0; font-size:20px;">${recoveryCode}</code>
|
||||||
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(recoveryModal);
|
document.body.appendChild(recoveryModal);
|
||||||
|
|
||||||
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
||||||
recoveryModal.remove();
|
recoveryModal.remove();
|
||||||
});
|
});
|
||||||
@@ -346,106 +455,54 @@ export function openTOTPModal() {
|
|||||||
const modalContentStyles = `
|
const modalContentStyles = `
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||||
padding: 20px;
|
padding: 20px; max-width:400px; width:90%; border-radius:8px; position:relative;
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
`;
|
||||||
if (!totpModal) {
|
if (!totpModal) {
|
||||||
totpModal = document.createElement("div");
|
totpModal = document.createElement("div");
|
||||||
totpModal.id = "totpModal";
|
totpModal.id = "totpModal";
|
||||||
totpModal.style.cssText = `
|
totpModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
top: 0;
|
background-color:${overlayBackground}; display:flex; justify-content:center; align-items:center;
|
||||||
left: 0;
|
z-index:3100;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: ${overlayBackground};
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3100;
|
|
||||||
`;
|
`;
|
||||||
totpModal.innerHTML = `
|
totpModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
||||||
<h3>${t("totp_setup")}</h3>
|
<h3>${t("totp_setup")}</h3>
|
||||||
<p>${t("scan_qr_code")}</p>
|
<p>${t("scan_qr_code")}</p>
|
||||||
<!-- Create an image placeholder without the CSRF token in the src -->
|
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width:100%; height:auto; display:block; margin:0 auto;" />
|
||||||
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
<br/>
|
||||||
<br/>
|
<p>${t("enter_totp_confirmation")}</p>
|
||||||
<p>${t("enter_totp_confirmation")}</p>
|
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
<br/><br/>
|
||||||
<br/><br/>
|
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
||||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
</div>
|
||||||
</div>
|
`;
|
||||||
`;
|
|
||||||
document.body.appendChild(totpModal);
|
document.body.appendChild(totpModal);
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
document.getElementById("closeTOTPModal").addEventListener("click", () => closeTOTPModal(true));
|
||||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
|
||||||
closeTOTPModal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
||||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) { showToast(t("please_enter_valid_code")); return; }
|
||||||
showToast(t("please_enter_valid_code"));
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
return;
|
if (!tokenRes.ok) { showToast(t("error_verifying_totp_code")); return; }
|
||||||
}
|
window.csrfToken = (await tokenRes.json()).csrf_token;
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
showToast(t("error_verifying_totp_code"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { csrf_token } = await tokenRes.json();
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
|
|
||||||
const verifyRes = await fetch("/api/totp_verify.php", {
|
const verifyRes = await fetch("/api/totp_verify.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ totp_code: code })
|
body: JSON.stringify({ totp_code: code })
|
||||||
});
|
});
|
||||||
|
if (!verifyRes.ok) { showToast(t("totp_verification_failed")); return; }
|
||||||
if (!verifyRes.ok) {
|
|
||||||
showToast(t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await verifyRes.json();
|
const result = await verifyRes.json();
|
||||||
if (result.status !== "ok") {
|
if (result.status !== "ok") { showToast(result.message || t("totp_verification_failed")); return; }
|
||||||
showToast(result.message || t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(t("totp_enabled_successfully"));
|
showToast(t("totp_enabled_successfully"));
|
||||||
|
|
||||||
const saveRes = await fetch("/api/totp_saveCode.php", {
|
const saveRes = await fetch("/api/totp_saveCode.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include", headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!saveRes.ok) {
|
if (!saveRes.ok) { showToast(t("error_generating_recovery_code")); closeTOTPModal(false); return; }
|
||||||
showToast(t("error_generating_recovery_code"));
|
|
||||||
closeTOTPModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await saveRes.json();
|
const data = await saveRes.json();
|
||||||
if (data.status === "ok" && data.recoveryCode) {
|
if (data.status === "ok" && data.recoveryCode) showRecoveryCodeModal(data.recoveryCode);
|
||||||
showRecoveryCodeModal(data.recoveryCode);
|
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
||||||
} else {
|
|
||||||
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTOTPModal(false);
|
closeTOTPModal(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,29 +515,18 @@ export function openTOTPModal() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
totpModal.style.display = "flex";
|
totpModal.style.display = "flex";
|
||||||
totpModal.style.backgroundColor = overlayBackground;
|
totpModal.style.backgroundColor = overlayBackground;
|
||||||
const modalContent = totpModal.querySelector(".modal-content");
|
const modalContent = totpModal.querySelector(".modal-content");
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||||
|
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||||
// Clear any previous QR code src if needed and then load it:
|
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = "";
|
|
||||||
}
|
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
const totpInput = document.getElementById("totpConfirmInput");
|
||||||
// Focus the input and attach enter key listener
|
if (totpInput) {
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
totpInput.value = "";
|
||||||
if (totpConfirmInput) {
|
setTimeout(() => totpInput.focus(), 100);
|
||||||
totpConfirmInput.value = "";
|
|
||||||
setTimeout(() => {
|
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
|
||||||
if (totpConfirmInput) totpConfirmInput.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
}
|
}
|
||||||
@@ -490,42 +536,31 @@ function loadTOTPQRCode() {
|
|||||||
fetch("/api/totp_setup.php", {
|
fetch("/api/totp_setup.php", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
"X-CSRF-Token": window.csrfToken // Send your CSRF token here
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(res => {
|
||||||
if (!response.ok) {
|
if (!res.ok) throw new Error("Failed to fetch QR code: " + res.status);
|
||||||
throw new Error("Failed to fetch QR code. Status: " + response.status);
|
return res.blob();
|
||||||
}
|
|
||||||
return response.blob();
|
|
||||||
})
|
})
|
||||||
.then(blob => {
|
.then(blob => {
|
||||||
const imageURL = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
document.getElementById("totpQRCodeImage").src = url;
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = imageURL;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(err => {
|
||||||
console.error("Error loading TOTP QR code:", error);
|
console.error(err);
|
||||||
showToast(t("error_loading_qr_code"));
|
showToast(t("error_loading_qr_code"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated closeTOTPModal function with a disable parameter
|
|
||||||
export function closeTOTPModal(disable = true) {
|
export function closeTOTPModal(disable = true) {
|
||||||
const totpModal = document.getElementById("totpModal");
|
const totpModal = document.getElementById("totpModal");
|
||||||
if (totpModal) totpModal.style.display = "none";
|
if (totpModal) totpModal.style.display = "none";
|
||||||
|
|
||||||
if (disable) {
|
if (disable) {
|
||||||
// Uncheck the Enable TOTP checkbox
|
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||||
if (totpCheckbox) {
|
if (totpCheckbox) {
|
||||||
totpCheckbox.checked = false;
|
totpCheckbox.checked = false;
|
||||||
localStorage.setItem("userTOTPEnabled", "false");
|
localStorage.setItem("userTOTPEnabled", "false");
|
||||||
}
|
}
|
||||||
// Call endpoint to remove the TOTP secret from the user's record
|
|
||||||
fetch("/api/totp_disable.php", {
|
fetch("/api/totp_disable.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -536,10 +571,36 @@ export function closeTOTPModal(disable = true) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success) {
|
if (!result.success) showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
||||||
showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => { showToast(t("error_disabling_totp_setting")); });
|
.catch(() => showToast(t("error_disabling_totp_setting")));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openApiModal() {
|
||||||
|
let apiModal = document.getElementById("apiModal");
|
||||||
|
if (!apiModal) {
|
||||||
|
// create the container exactly as you do now inside openUserPanel
|
||||||
|
apiModal = document.createElement("div");
|
||||||
|
apiModal.id = "apiModal";
|
||||||
|
apiModal.style.cssText = `
|
||||||
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
`;
|
||||||
|
apiModal.innerHTML = `
|
||||||
|
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||||||
|
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||||||
|
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(apiModal);
|
||||||
|
|
||||||
|
// wire up its close button
|
||||||
|
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// finally, show it
|
||||||
|
apiModal.style.display = "flex";
|
||||||
}
|
}
|
||||||
@@ -33,54 +33,66 @@ export function toggleAllCheckboxes(masterCheckbox) {
|
|||||||
export function updateFileActionButtons() {
|
export function updateFileActionButtons() {
|
||||||
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||||
const copyBtn = document.getElementById("copySelectedBtn");
|
const copyBtn = document.getElementById("copySelectedBtn");
|
||||||
const moveBtn = document.getElementById("moveSelectedBtn");
|
const moveBtn = document.getElementById("moveSelectedBtn");
|
||||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
|
||||||
const zipBtn = document.getElementById("downloadZipBtn");
|
const zipBtn = document.getElementById("downloadZipBtn");
|
||||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||||
|
const createBtn = document.getElementById("createBtn");
|
||||||
|
|
||||||
// keep the “select all” in sync ——
|
const anyFiles = fileCheckboxes.length > 0;
|
||||||
const master = document.getElementById("selectAll");
|
const anySelected = selectedCheckboxes.length > 0;
|
||||||
if (master) {
|
const anyZip = Array.from(selectedCheckboxes)
|
||||||
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
.some(cb => cb.value.toLowerCase().endsWith(".zip"));
|
||||||
master.checked = true;
|
|
||||||
master.indeterminate = false;
|
|
||||||
} else if (selectedCheckboxes.length === 0) {
|
|
||||||
master.checked = false;
|
|
||||||
master.indeterminate = false;
|
|
||||||
} else {
|
|
||||||
master.checked = false;
|
|
||||||
master.indeterminate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileCheckboxes.length === 0) {
|
// — Select All checkbox sync (unchanged) —
|
||||||
if (copyBtn) copyBtn.style.display = "none";
|
const master = document.getElementById("selectAll");
|
||||||
if (moveBtn) moveBtn.style.display = "none";
|
if (master) {
|
||||||
if (deleteBtn) deleteBtn.style.display = "none";
|
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
||||||
if (zipBtn) zipBtn.style.display = "none";
|
master.checked = true;
|
||||||
if (extractZipBtn) extractZipBtn.style.display = "none";
|
master.indeterminate = false;
|
||||||
} else {
|
} else if (selectedCheckboxes.length === 0) {
|
||||||
if (copyBtn) copyBtn.style.display = "inline-block";
|
master.checked = false;
|
||||||
if (moveBtn) moveBtn.style.display = "inline-block";
|
master.indeterminate = false;
|
||||||
if (deleteBtn) deleteBtn.style.display = "inline-block";
|
} else {
|
||||||
if (zipBtn) zipBtn.style.display = "inline-block";
|
master.checked = false;
|
||||||
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
|
master.indeterminate = true;
|
||||||
|
|
||||||
const anySelected = selectedCheckboxes.length > 0;
|
|
||||||
if (copyBtn) copyBtn.disabled = !anySelected;
|
|
||||||
if (moveBtn) moveBtn.disabled = !anySelected;
|
|
||||||
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
|
||||||
if (zipBtn) zipBtn.disabled = !anySelected;
|
|
||||||
|
|
||||||
if (extractZipBtn) {
|
|
||||||
// Enable only if at least one selected file ends with .zip (case-insensitive).
|
|
||||||
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
|
|
||||||
chk.value.toLowerCase().endsWith(".zip")
|
|
||||||
);
|
|
||||||
extractZipBtn.disabled = !anyZipSelected;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete / Copy / Move: only show when something is selected
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
if (moveBtn) {
|
||||||
|
moveBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download ZIP: only show when something is selected
|
||||||
|
if (zipBtn) {
|
||||||
|
zipBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ZIP: only show when a selected file is a .zip
|
||||||
|
if (extractZipBtn) {
|
||||||
|
extractZipBtn.style.display = anyZip ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create File: only show when nothing is selected
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.style.display = anySelected ? "none" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally disable the ones that are shown but shouldn’t be clickable
|
||||||
|
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
||||||
|
if (copyBtn) copyBtn.disabled = !anySelected;
|
||||||
|
if (moveBtn) moveBtn.disabled = !anySelected;
|
||||||
|
if (zipBtn) zipBtn.disabled = !anySelected;
|
||||||
|
if (extractZipBtn) extractZipBtn.disabled = !anyZip;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(message, duration = 3000) {
|
export function showToast(message, duration = 3000) {
|
||||||
@@ -178,9 +190,14 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
|
previewButton = `<button
|
||||||
${previewIcon}
|
type="button"
|
||||||
</button>`;
|
class="btn btn-sm btn-info preview-btn"
|
||||||
|
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||||
|
data-preview-name="${safeFileName}"
|
||||||
|
title="${t('preview')}">
|
||||||
|
${previewIcon}
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -194,19 +211,44 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap">${safeSize}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-success download-btn"
|
||||||
|
data-download-name="${file.name}"
|
||||||
|
data-download-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('download')}">
|
||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
|
<button
|
||||||
<i class="material-icons">edit</i>
|
type="button"
|
||||||
</button>
|
class="btn btn-sm btn-secondary edit-btn"
|
||||||
` : ""}
|
data-edit-name="${file.name}"
|
||||||
|
data-edit-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('edit')}">
|
||||||
|
<i class="material-icons">edit</i>
|
||||||
|
</button>` : ""}
|
||||||
|
|
||||||
${previewButton}
|
${previewButton}
|
||||||
<button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-warning rename-btn"
|
||||||
|
data-rename-name="${file.name}"
|
||||||
|
data-rename-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('rename')}">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- share -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||||
|
data-file="${safeFileName}"
|
||||||
|
title="${t('share')}">
|
||||||
|
<i class="material-icons">share</i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -32,23 +32,33 @@ export function loadSidebarOrder() {
|
|||||||
updateSidebarVisibility();
|
updateSidebarVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Load header order from localStorage.
|
|
||||||
export function loadHeaderOrder() {
|
export function loadHeaderOrder() {
|
||||||
const headerDropArea = document.getElementById('headerDropArea');
|
const headerDropArea = document.getElementById('headerDropArea');
|
||||||
if (!headerDropArea) return;
|
if (!headerDropArea) return;
|
||||||
const orderStr = localStorage.getItem('headerOrder');
|
|
||||||
if (orderStr) {
|
// 1) Clear out any icons that might already be in the drop area
|
||||||
const order = JSON.parse(orderStr);
|
headerDropArea.innerHTML = '';
|
||||||
if (order.length > 0) {
|
|
||||||
order.forEach(id => {
|
// 2) Read the saved array (or empty array if invalid/missing)
|
||||||
const card = document.getElementById(id);
|
let stored;
|
||||||
// Only load if card is not already in header drop zone.
|
try {
|
||||||
if (card && card.parentNode.id !== 'headerDropArea') {
|
stored = JSON.parse(localStorage.getItem('headerOrder') || '[]');
|
||||||
insertCardInHeader(card, null);
|
} catch {
|
||||||
}
|
stored = [];
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) Deduplicate IDs
|
||||||
|
const uniqueIds = Array.from(new Set(stored));
|
||||||
|
|
||||||
|
// 4) Re-insert exactly one icon per saved card ID
|
||||||
|
uniqueIds.forEach(id => {
|
||||||
|
const card = document.getElementById(id);
|
||||||
|
if (card) insertCardInHeader(card, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Persist the cleaned, deduped list back to storage
|
||||||
|
localStorage.setItem('headerOrder', JSON.stringify(uniqueIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper: update sidebar visibility based on its content.
|
// Internal helper: update sidebar visibility based on its content.
|
||||||
|
|||||||
@@ -76,6 +76,72 @@ export function handleDownloadZipSelected(e) {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function handleCreateFileSelected(e) {
|
||||||
|
e.preventDefault(); e.stopImmediatePropagation();
|
||||||
|
const modal = document.getElementById('createFileModal');
|
||||||
|
modal.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
const inp = document.getElementById('newFileCreateName');
|
||||||
|
if (inp) inp.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the “New File” modal
|
||||||
|
*/
|
||||||
|
export function openCreateFileModal() {
|
||||||
|
const modal = document.getElementById('createFileModal');
|
||||||
|
const input = document.getElementById('createFileNameInput');
|
||||||
|
if (!modal || !input) {
|
||||||
|
console.error('Create-file modal or input not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function handleCreateFile(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('createFileNameInput');
|
||||||
|
if (!input) return console.error('Create-file input missing');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast(t('newfile_placeholder')); // or a more explicit error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/file/createFile.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type':'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
// ⚠️ must send `name`, not `filename`
|
||||||
|
body: JSON.stringify({ folder, name })
|
||||||
|
});
|
||||||
|
const js = await res.json();
|
||||||
|
if (!js.success) throw new Error(js.error);
|
||||||
|
showToast(t('file_created'));
|
||||||
|
loadFileList(folder);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || t('error_creating_file'));
|
||||||
|
} finally {
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const cancel = document.getElementById('cancelCreateFile');
|
||||||
|
const confirm = document.getElementById('confirmCreateFile');
|
||||||
|
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||||
|
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||||
|
});
|
||||||
|
|
||||||
export function openDownloadModal(fileName, folder) {
|
export function openDownloadModal(fileName, folder) {
|
||||||
// Store file details globally for the download confirmation function.
|
// Store file details globally for the download confirmation function.
|
||||||
window.singleFileToDownload = fileName;
|
window.singleFileToDownload = fileName;
|
||||||
@@ -197,6 +263,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const progressModal = document.getElementById("downloadProgressModal");
|
const progressModal = document.getElementById("downloadProgressModal");
|
||||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
|
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||||
|
|
||||||
|
if (cancelCreate) {
|
||||||
|
cancelCreate.addEventListener('click', () => {
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCreate = document.getElementById('confirmCreateFile');
|
||||||
|
if (confirmCreate) {
|
||||||
|
confirmCreate.addEventListener('click', async () => {
|
||||||
|
const name = document.getElementById('newFileCreateName').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast(t('please_enter_filename'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/file/createFile.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder: window.currentFolder || 'root',
|
||||||
|
filename: name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const js = await res.json();
|
||||||
|
if (!res.ok || !js.success) {
|
||||||
|
throw new Error(js.error || t('error_creating_file'));
|
||||||
|
}
|
||||||
|
showToast(t('file_created_successfully'));
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast(err.message || t('error_creating_file'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Cancel button hides the name modal
|
// 1) Cancel button hides the name modal
|
||||||
if (cancelZipBtn) {
|
if (cancelZipBtn) {
|
||||||
@@ -553,8 +662,14 @@ export function initFileActions() {
|
|||||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||||
}
|
}
|
||||||
|
const createBtn = document.getElementById('createFileBtn');
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.replaceWith(createBtn.cloneNode(true));
|
||||||
|
document.getElementById('createFileBtn').addEventListener('click', openCreateFileModal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Hook up the single‐file download modal buttons
|
// Hook up the single‐file download modal buttons
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
||||||
@@ -573,4 +688,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const btn = document.getElementById('createBtn');
|
||||||
|
const menu = document.getElementById('createMenu');
|
||||||
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
|
const folderOpt= document.getElementById('createFolderOption');
|
||||||
|
|
||||||
|
// Toggle dropdown on click
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create File
|
||||||
|
fileOpt.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
openCreateFileModal(); // your existing function
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Folder
|
||||||
|
folderOpt.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
document.getElementById('createFolderModal').style.display = 'block';
|
||||||
|
document.getElementById('newFolderName').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close if you click anywhere else
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -3,20 +3,143 @@ import { escapeHTML, showToast } from './domUtils.js';
|
|||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
|
|
||||||
|
// thresholds for editor behavior
|
||||||
|
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||||
|
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||||
|
|
||||||
|
// Lazy-load CodeMirror modes on demand
|
||||||
|
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||||
|
const MODE_URL = {
|
||||||
|
// core you've likely already loaded:
|
||||||
|
"xml": "mode/xml/xml.min.js",
|
||||||
|
"css": "mode/css/css.min.js",
|
||||||
|
"javascript": "mode/javascript/javascript.min.js",
|
||||||
|
|
||||||
|
// extras you may want on-demand:
|
||||||
|
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
||||||
|
"application/x-httpd-php": "mode/php/php.min.js",
|
||||||
|
"php": "mode/php/php.min.js",
|
||||||
|
"markdown": "mode/markdown/markdown.min.js",
|
||||||
|
"python": "mode/python/python.min.js",
|
||||||
|
"sql": "mode/sql/sql.min.js",
|
||||||
|
"shell": "mode/shell/shell.min.js",
|
||||||
|
"yaml": "mode/yaml/yaml.min.js",
|
||||||
|
"properties": "mode/properties/properties.min.js",
|
||||||
|
"text/x-csrc": "mode/clike/clike.min.js",
|
||||||
|
"text/x-c++src": "mode/clike/clike.min.js",
|
||||||
|
"text/x-java": "mode/clike/clike.min.js",
|
||||||
|
"text/x-csharp": "mode/clike/clike.min.js",
|
||||||
|
"text/x-kotlin": "mode/clike/clike.min.js"
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadScriptOnce(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const key = `cm:${url}`;
|
||||||
|
let s = document.querySelector(`script[data-key="${key}"]`);
|
||||||
|
if (s) {
|
||||||
|
if (s.dataset.loaded === "1") return resolve();
|
||||||
|
s.addEventListener("load", () => resolve());
|
||||||
|
s.addEventListener("error", reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s = document.createElement("script");
|
||||||
|
s.src = url;
|
||||||
|
s.defer = true;
|
||||||
|
s.dataset.key = key;
|
||||||
|
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||||
|
s.addEventListener("error", reject);
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureModeLoaded(modeOption) {
|
||||||
|
if (!window.CodeMirror) return; // CM core must be present
|
||||||
|
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||||
|
if (!name) return;
|
||||||
|
// Already registered?
|
||||||
|
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = MODE_URL[name];
|
||||||
|
if (!url) return; // unknown -> fallback to text/plain
|
||||||
|
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
|
||||||
|
if (name === "htmlmixed") {
|
||||||
|
await Promise.all([
|
||||||
|
ensureModeLoaded("xml"),
|
||||||
|
ensureModeLoaded("css"),
|
||||||
|
ensureModeLoaded("javascript")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (name === "application/x-httpd-php") {
|
||||||
|
await ensureModeLoaded("htmlmixed");
|
||||||
|
}
|
||||||
|
await loadScriptOnce(CM_CDN + url);
|
||||||
|
}
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
const dot = fileName.lastIndexOf(".");
|
||||||
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
|
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case "css":
|
// markup
|
||||||
return "css";
|
|
||||||
case "json":
|
|
||||||
return { name: "javascript", json: true };
|
|
||||||
case "js":
|
|
||||||
return "javascript";
|
|
||||||
case "html":
|
case "html":
|
||||||
case "htm":
|
case "htm":
|
||||||
return "text/html";
|
return "text/html"; // ensureModeLoaded will map to htmlmixed
|
||||||
case "xml":
|
case "xml":
|
||||||
return "xml";
|
return "xml";
|
||||||
|
case "md":
|
||||||
|
case "markdown":
|
||||||
|
return "markdown";
|
||||||
|
case "yml":
|
||||||
|
case "yaml":
|
||||||
|
return "yaml";
|
||||||
|
|
||||||
|
// styles & scripts
|
||||||
|
case "css":
|
||||||
|
return "css";
|
||||||
|
case "js":
|
||||||
|
return "javascript";
|
||||||
|
case "json":
|
||||||
|
return { name: "javascript", json: true };
|
||||||
|
|
||||||
|
// server / langs
|
||||||
|
case "php":
|
||||||
|
return "application/x-httpd-php";
|
||||||
|
case "py":
|
||||||
|
return "python";
|
||||||
|
case "sql":
|
||||||
|
return "sql";
|
||||||
|
case "sh":
|
||||||
|
case "bash":
|
||||||
|
case "zsh":
|
||||||
|
case "bat":
|
||||||
|
return "shell";
|
||||||
|
|
||||||
|
// config-y files
|
||||||
|
case "ini":
|
||||||
|
case "conf":
|
||||||
|
case "config":
|
||||||
|
case "properties":
|
||||||
|
return "properties";
|
||||||
|
|
||||||
|
// C-family / JVM
|
||||||
|
case "c":
|
||||||
|
case "h":
|
||||||
|
return "text/x-csrc";
|
||||||
|
case "cpp":
|
||||||
|
case "cxx":
|
||||||
|
case "hpp":
|
||||||
|
case "hh":
|
||||||
|
case "hxx":
|
||||||
|
return "text/x-c++src";
|
||||||
|
case "java":
|
||||||
|
return "text/x-java";
|
||||||
|
case "cs":
|
||||||
|
return "text/x-csharp";
|
||||||
|
case "kt":
|
||||||
|
case "kts":
|
||||||
|
return "text/x-kotlin";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "text/plain";
|
return "text/plain";
|
||||||
}
|
}
|
||||||
@@ -47,6 +170,7 @@ export function editFile(fileName, folder) {
|
|||||||
if (existingEditor) {
|
if (existingEditor) {
|
||||||
existingEditor.remove();
|
existingEditor.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderUsed = folder || window.currentFolder || "root";
|
const folderUsed = folder || window.currentFolder || "root";
|
||||||
const folderPath = folderUsed === "root"
|
const folderPath = folderUsed === "root"
|
||||||
? "uploads/"
|
? "uploads/"
|
||||||
@@ -55,26 +179,40 @@ export function editFile(fileName, folder) {
|
|||||||
|
|
||||||
fetch(fileUrl, { method: "HEAD" })
|
fetch(fileUrl, { method: "HEAD" })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const contentLength = response.headers.get("Content-Length");
|
const lenHeader =
|
||||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
response.headers.get("content-length") ??
|
||||||
|
response.headers.get("Content-Length");
|
||||||
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
|
|
||||||
|
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||||
throw new Error("File too large.");
|
throw new Error("File too large.");
|
||||||
}
|
}
|
||||||
return fetch(fileUrl);
|
return response;
|
||||||
})
|
})
|
||||||
|
.then(() => fetch(fileUrl))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("HTTP error! Status: " + response.status);
|
throw new Error("HTTP error! Status: " + response.status);
|
||||||
}
|
}
|
||||||
return response.text();
|
const lenHeader =
|
||||||
|
response.headers.get("content-length") ??
|
||||||
|
response.headers.get("Content-Length");
|
||||||
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
|
return Promise.all([response.text(), sizeBytes]);
|
||||||
})
|
})
|
||||||
.then(content => {
|
.then(([content, sizeBytes]) => {
|
||||||
|
const forcePlainText =
|
||||||
|
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||||
|
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.id = "editorContainer";
|
modal.id = "editorContainer";
|
||||||
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">${t("editing")}: ${escapeHTML(fileName)}</h3>
|
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
|
||||||
|
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
|
||||||
|
}</h3>
|
||||||
<div class="editor-controls">
|
<div class="editor-controls">
|
||||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||||
@@ -90,61 +228,74 @@ export function editFile(fileName, folder) {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = "block";
|
modal.style.display = "block";
|
||||||
|
|
||||||
const mode = getModeForFile(fileName);
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
const theme = isDarkMode ? "material-darker" : "default";
|
const theme = isDarkMode ? "material-darker" : "default";
|
||||||
|
|
||||||
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
|
// choose mode + lighter settings for large files
|
||||||
lineNumbers: true,
|
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||||
|
const cmOptions = {
|
||||||
|
lineNumbers: !forcePlainText,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
viewportMargin: Infinity
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
});
|
lineWrapping: false,
|
||||||
|
};
|
||||||
|
|
||||||
window.currentEditor = editor;
|
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
|
||||||
|
ensureModeLoaded(mode).finally(() => {
|
||||||
|
const editor = CodeMirror.fromTextArea(
|
||||||
|
document.getElementById("fileEditor"),
|
||||||
|
cmOptions
|
||||||
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
window.currentEditor = editor;
|
||||||
adjustEditorSize();
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
observeModalResize(modal);
|
setTimeout(() => {
|
||||||
|
adjustEditorSize();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
let currentFontSize = 14;
|
observeModalResize(modal);
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
|
||||||
editor.refresh();
|
|
||||||
|
|
||||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
let currentFontSize = 14;
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
|
|
||||||
|
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||||
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
|
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||||
|
editor.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("increaseFont").addEventListener("click", function () {
|
||||||
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
|
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||||
|
editor.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||||
|
saveFile(fileName, folderUsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateEditorTheme() {
|
||||||
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
|
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
|
}
|
||||||
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
|
||||||
editor.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
|
||||||
saveFile(fileName, folderUsed);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateEditorTheme() {
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
|
||||||
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error loading file:", error));
|
.catch(error => {
|
||||||
|
if (error && error.name === "AbortError") return;
|
||||||
|
console.error("Error loading file:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,31 @@ import { t } from './i18n.js';
|
|||||||
import { bindFileListContextMenu } from './fileMenu.js';
|
import { bindFileListContextMenu } from './fileMenu.js';
|
||||||
import { openDownloadModal } from './fileActions.js';
|
import { openDownloadModal } from './fileActions.js';
|
||||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
||||||
|
import {
|
||||||
|
getParentFolder,
|
||||||
|
updateBreadcrumbTitle,
|
||||||
|
setupBreadcrumbDelegation,
|
||||||
|
showFolderManagerContextMenu,
|
||||||
|
hideFolderManagerContextMenu,
|
||||||
|
openRenameFolderModal,
|
||||||
|
openDeleteFolderModal
|
||||||
|
} from './folderManager.js';
|
||||||
|
import { openFolderShareModal } from './folderShareModal.js';
|
||||||
|
import {
|
||||||
|
folderDragOverHandler,
|
||||||
|
folderDragLeaveHandler,
|
||||||
|
folderDropHandler
|
||||||
|
} from './fileDragDrop.js';
|
||||||
|
|
||||||
export let fileData = [];
|
export let fileData = [];
|
||||||
export let sortOrder = { column: "uploaded", ascending: true };
|
export let sortOrder = { column: "uploaded", ascending: true };
|
||||||
|
|
||||||
|
// Hide "Edit" for files >10 MiB
|
||||||
|
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
|
||||||
|
let __fileListReqSeq = 0;
|
||||||
|
|
||||||
window.itemsPerPage = parseInt(
|
window.itemsPerPage = parseInt(
|
||||||
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
|
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
|
||||||
10
|
10
|
||||||
@@ -186,100 +207,323 @@ export function formatFolderName(folder) {
|
|||||||
window.toggleRowSelection = toggleRowSelection;
|
window.toggleRowSelection = toggleRowSelection;
|
||||||
window.updateRowHighlight = updateRowHighlight;
|
window.updateRowHighlight = updateRowHighlight;
|
||||||
|
|
||||||
/**
|
export async function loadFileList(folderParam) {
|
||||||
* --- FILE LIST & VIEW RENDERING ---
|
const reqId = ++__fileListReqSeq; // latest call wins
|
||||||
*/
|
|
||||||
export function loadFileList(folderParam) {
|
|
||||||
const folder = folderParam || "root";
|
const folder = folderParam || "root";
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const fileListContainer = document.getElementById("fileList");
|
||||||
|
const actionsContainer = document.getElementById("fileListActions");
|
||||||
|
|
||||||
|
// 1) show loader (only this request is allowed to render)
|
||||||
fileListContainer.style.visibility = "hidden";
|
fileListContainer.style.visibility = "hidden";
|
||||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||||
|
|
||||||
return fetch("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
|
try {
|
||||||
.then(response => {
|
// Kick off both in parallel, but we'll render as soon as FILES are ready
|
||||||
if (response.status === 401) {
|
const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`);
|
||||||
showToast("Session expired. Please log in again.");
|
const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`);
|
||||||
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.
|
// ----- FILES FIRST -----
|
||||||
const actionsContainer = document.getElementById("fileListActions");
|
const filesRes = await filesPromise;
|
||||||
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 (filesRes.status === 401) {
|
||||||
if (window.viewMode === "gallery") {
|
window.location.href = "/api/auth/logout.php";
|
||||||
renderGalleryView(folder);
|
throw new Error("Unauthorized");
|
||||||
updateFileActionButtons();
|
}
|
||||||
} else {
|
|
||||||
renderFileTable(folder);
|
const data = await filesRes.json();
|
||||||
}
|
|
||||||
} else {
|
// If another loadFileList ran after this one, bail before touching the DOM
|
||||||
fileListContainer.textContent = t("no_files_found");
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
const summaryElem = document.getElementById("fileSummary");
|
|
||||||
if (summaryElem) {
|
// 3) clear loader (still only if this request is the latest)
|
||||||
summaryElem.style.display = "none";
|
fileListContainer.innerHTML = "";
|
||||||
}
|
|
||||||
updateFileActionButtons();
|
// 4) handle “no files” case
|
||||||
}
|
if (!data.files || Object.keys(data.files).length === 0) {
|
||||||
return data.files || [];
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
})
|
fileListContainer.textContent = t("no_files_found");
|
||||||
.catch(error => {
|
|
||||||
console.error("Error loading file list:", error);
|
// hide summary + slider
|
||||||
if (error.message !== "Unauthorized") {
|
const summaryElem = document.getElementById("fileSummary");
|
||||||
fileListContainer.textContent = "Error loading files.";
|
if (summaryElem) summaryElem.style.display = "none";
|
||||||
}
|
const sliderContainer = document.getElementById("viewSliderContainer");
|
||||||
return [];
|
if (sliderContainer) sliderContainer.style.display = "none";
|
||||||
})
|
|
||||||
.finally(() => {
|
// hide folder strip for now; we’ll re-show it after folders load (below)
|
||||||
|
const strip = document.getElementById("folderStripContainer");
|
||||||
|
if (strip) strip.style.display = "none";
|
||||||
|
|
||||||
|
updateFileActionButtons();
|
||||||
fileListContainer.style.visibility = "visible";
|
fileListContainer.style.visibility = "visible";
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) normalize files array
|
||||||
|
if (!Array.isArray(data.files)) {
|
||||||
|
data.files = Object.entries(data.files).map(([name, meta]) => {
|
||||||
|
meta.name = name;
|
||||||
|
return meta;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
data.files = data.files.map(f => {
|
||||||
|
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||||||
|
|
||||||
|
// Prefer numeric size if your API provides it; otherwise parse the "1.2 MB" string
|
||||||
|
let bytes = Number.isFinite(f.sizeBytes)
|
||||||
|
? f.sizeBytes
|
||||||
|
: parseSizeToBytes(String(f.size || ""));
|
||||||
|
|
||||||
|
if (!Number.isFinite(bytes)) bytes = Infinity;
|
||||||
|
|
||||||
|
// extension policy + size policy
|
||||||
|
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
|
||||||
|
|
||||||
|
f.folder = folder;
|
||||||
|
return f;
|
||||||
});
|
});
|
||||||
|
fileData = data.files;
|
||||||
|
|
||||||
|
// Decide editability BEFORE render to avoid any post-render “blink”
|
||||||
|
data.files = data.files.map(f => {
|
||||||
|
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||||||
|
|
||||||
|
// extension policy
|
||||||
|
const extOk = canEditFile(f.name);
|
||||||
|
|
||||||
|
// prefer numeric byte size if API provides it; otherwise parse "12.3 MB" strings
|
||||||
|
let bytes = Infinity;
|
||||||
|
if (Number.isFinite(f.sizeBytes)) {
|
||||||
|
bytes = f.sizeBytes;
|
||||||
|
} else if (f.size != null && String(f.size).trim() !== "") {
|
||||||
|
bytes = parseSizeToBytes(String(f.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
f.editable = extOk && (bytes <= MAX_EDIT_BYTES);
|
||||||
|
f.folder = folder;
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
fileData = data.files;
|
||||||
|
|
||||||
|
// If stale, stop before any DOM updates
|
||||||
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
|
|
||||||
|
// 6) inject summary + slider
|
||||||
|
if (actionsContainer) {
|
||||||
|
// a) summary
|
||||||
|
let summaryElem = document.getElementById("fileSummary");
|
||||||
|
if (!summaryElem) {
|
||||||
|
summaryElem = document.createElement("div");
|
||||||
|
summaryElem.id = "fileSummary";
|
||||||
|
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
|
||||||
|
actionsContainer.appendChild(summaryElem);
|
||||||
|
}
|
||||||
|
summaryElem.style.display = "block";
|
||||||
|
summaryElem.innerHTML = buildFolderSummary(fileData);
|
||||||
|
|
||||||
|
// b) slider
|
||||||
|
const viewMode = window.viewMode || "table";
|
||||||
|
let sliderContainer = document.getElementById("viewSliderContainer");
|
||||||
|
if (!sliderContainer) {
|
||||||
|
sliderContainer = document.createElement("div");
|
||||||
|
sliderContainer.id = "viewSliderContainer";
|
||||||
|
sliderContainer.style.cssText = "display:inline-flex; align-items:center; margin-right:auto; font-size:0.9em;";
|
||||||
|
actionsContainer.insertBefore(sliderContainer, summaryElem);
|
||||||
|
} else {
|
||||||
|
sliderContainer.style.display = "inline-flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === "gallery") {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
let maxCols;
|
||||||
|
if (w < 600) maxCols = 1;
|
||||||
|
else if (w < 900) maxCols = 2;
|
||||||
|
else if (w < 1200) maxCols = 4;
|
||||||
|
else maxCols = 6;
|
||||||
|
|
||||||
|
const currentCols = Math.min(
|
||||||
|
parseInt(localStorage.getItem("galleryColumns") || "3", 10),
|
||||||
|
maxCols
|
||||||
|
);
|
||||||
|
|
||||||
|
sliderContainer.innerHTML = `
|
||||||
|
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
|
||||||
|
${t("columns")}:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="galleryColumnsSlider"
|
||||||
|
min="1"
|
||||||
|
max="${maxCols}"
|
||||||
|
value="${currentCols}"
|
||||||
|
style="vertical-align:middle;"
|
||||||
|
>
|
||||||
|
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
|
||||||
|
`;
|
||||||
|
const gallerySlider = document.getElementById("galleryColumnsSlider");
|
||||||
|
const galleryValue = document.getElementById("galleryColumnsValue");
|
||||||
|
gallerySlider.oninput = e => {
|
||||||
|
const v = +e.target.value;
|
||||||
|
localStorage.setItem("galleryColumns", v);
|
||||||
|
galleryValue.textContent = v;
|
||||||
|
document.querySelector(".gallery-container")
|
||||||
|
?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
|
||||||
|
sliderContainer.innerHTML = `
|
||||||
|
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
|
||||||
|
${t("row_height")}:
|
||||||
|
</label>
|
||||||
|
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
|
||||||
|
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
|
||||||
|
`;
|
||||||
|
const rowSlider = document.getElementById("rowHeightSlider");
|
||||||
|
const rowValue = document.getElementById("rowHeightValue");
|
||||||
|
rowSlider.oninput = e => {
|
||||||
|
const v = e.target.value;
|
||||||
|
document.documentElement.style.setProperty("--file-row-height", v + "px");
|
||||||
|
localStorage.setItem("rowHeight", v);
|
||||||
|
rowValue.textContent = v + "px";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) render files (only if still latest)
|
||||||
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
|
|
||||||
|
if (window.viewMode === "gallery") {
|
||||||
|
renderGalleryView(folder);
|
||||||
|
} else {
|
||||||
|
renderFileTable(folder);
|
||||||
|
}
|
||||||
|
updateFileActionButtons();
|
||||||
|
fileListContainer.style.visibility = "visible";
|
||||||
|
|
||||||
|
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||||
|
try {
|
||||||
|
const foldersRes = await foldersPromise;
|
||||||
|
const folderRaw = await foldersRes.json();
|
||||||
|
if (reqId !== __fileListReqSeq) return data.files;
|
||||||
|
|
||||||
|
// --- build ONLY the *direct* children of current folder ---
|
||||||
|
let subfolders = [];
|
||||||
|
const hidden = new Set(["profile_pics", "trash"]);
|
||||||
|
if (Array.isArray(folderRaw)) {
|
||||||
|
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||||||
|
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||||||
|
subfolders = allPaths
|
||||||
|
.filter(p => {
|
||||||
|
if (folder === "root") return p.indexOf("/") === -1;
|
||||||
|
if (!p.startsWith(folder + "/")) return false;
|
||||||
|
return p.split("/").length === depth;
|
||||||
|
})
|
||||||
|
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||||||
|
}
|
||||||
|
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||||
|
|
||||||
|
// inject folder strip below actions, above file list
|
||||||
|
let strip = document.getElementById("folderStripContainer");
|
||||||
|
if (!strip) {
|
||||||
|
strip = document.createElement("div");
|
||||||
|
strip.id = "folderStripContainer";
|
||||||
|
strip.className = "folder-strip-container";
|
||||||
|
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showFoldersInList && subfolders.length) {
|
||||||
|
strip.innerHTML = subfolders.map(sf => `
|
||||||
|
<div class="folder-item" data-folder="${sf.full}" draggable="true">
|
||||||
|
<i class="material-icons">folder</i>
|
||||||
|
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
strip.style.display = "flex";
|
||||||
|
|
||||||
|
// wire up each folder‐tile
|
||||||
|
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||||
|
// 1) click to navigate
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
const dest = el.dataset.folder;
|
||||||
|
window.currentFolder = dest;
|
||||||
|
localStorage.setItem("lastOpenedFolder", dest);
|
||||||
|
updateBreadcrumbTitle(dest);
|
||||||
|
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
||||||
|
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
||||||
|
loadFileList(dest);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) drag & drop
|
||||||
|
el.addEventListener("dragover", folderDragOverHandler);
|
||||||
|
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||||
|
el.addEventListener("drop", folderDropHandler);
|
||||||
|
|
||||||
|
// 3) right-click context menu
|
||||||
|
el.addEventListener("contextmenu", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const dest = el.dataset.folder;
|
||||||
|
window.currentFolder = dest;
|
||||||
|
localStorage.setItem("lastOpenedFolder", dest);
|
||||||
|
|
||||||
|
// highlight the strip tile
|
||||||
|
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||||
|
el.classList.add("selected");
|
||||||
|
|
||||||
|
// reuse folderManager menu
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: t("create_folder"),
|
||||||
|
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("rename_folder"),
|
||||||
|
action: () => openRenameFolderModal()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("folder_share"),
|
||||||
|
action: () => openFolderShareModal(dest)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("delete_folder"),
|
||||||
|
action: () => openDeleteFolderModal()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// one global click to hide any open context menu
|
||||||
|
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
strip.style.display = "none";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore folder errors; rows already rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.files;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading file list:", err);
|
||||||
|
if (err.message !== "Unauthorized") {
|
||||||
|
fileListContainer.textContent = "Error loading files.";
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
// Only the latest call should restore visibility
|
||||||
|
if (reqId === __fileListReqSeq) {
|
||||||
|
fileListContainer.style.visibility = "visible";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update renderFileTable so it writes its content into the provided container.
|
* Update renderFileTable so it writes its content into the provided container.
|
||||||
*/
|
*/
|
||||||
export function renderFileTable(folder, container) {
|
export function renderFileTable(folder, container, subfolders) {
|
||||||
const fileListContent = container || document.getElementById("fileList");
|
const fileListContent = container || document.getElementById("fileList");
|
||||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
||||||
@@ -327,9 +571,6 @@ export function renderFileTable(folder, container) {
|
|||||||
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
||||||
return p1 + p2 + tagBadgesHTML + p3;
|
return p1 + p2 + tagBadgesHTML + p3;
|
||||||
});
|
});
|
||||||
rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `<button class="share-btn btn btn-sm btn-secondary" data-file="${escapeHTML(file.name)}" title="${t('share')}">
|
|
||||||
<i class="material-icons">share</i>
|
|
||||||
</button>$1`);
|
|
||||||
rowsHTML += rowHTML;
|
rowsHTML += rowHTML;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -340,6 +581,10 @@ export function renderFileTable(folder, container) {
|
|||||||
|
|
||||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||||
|
|
||||||
|
fileListContent.querySelectorAll('.folder-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => loadFileList(el.dataset.folder));
|
||||||
|
});
|
||||||
|
|
||||||
// pagination clicks
|
// pagination clicks
|
||||||
const prevBtn = document.getElementById("prevPageBtn");
|
const prevBtn = document.getElementById("prevPageBtn");
|
||||||
if (prevBtn) prevBtn.addEventListener("click", () => {
|
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||||||
@@ -414,7 +659,7 @@ export function renderFileTable(folder, container) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5) Preview buttons (if you still have a .preview-btn)
|
// 5) Preview buttons
|
||||||
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -441,6 +686,17 @@ export function renderFileTable(folder, container) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, 300));
|
}, 300));
|
||||||
}
|
}
|
||||||
|
const slider = document.getElementById('rowHeightSlider');
|
||||||
|
const valueDisplay = document.getElementById('rowHeightValue');
|
||||||
|
if (slider) {
|
||||||
|
slider.addEventListener('input', e => {
|
||||||
|
const v = +e.target.value; // slider value in px
|
||||||
|
document.documentElement.style.setProperty('--file-row-height', v + 'px');
|
||||||
|
localStorage.setItem('rowHeight', v);
|
||||||
|
valueDisplay.textContent = v + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
|
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
|
||||||
cell.addEventListener("click", function () {
|
cell.addEventListener("click", function () {
|
||||||
const column = this.getAttribute("data-column");
|
const column = this.getAttribute("data-column");
|
||||||
@@ -530,18 +786,17 @@ export function renderGalleryView(folder, container) {
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// --- Column slider ---
|
// --- Column slider with responsive max ---
|
||||||
const numColumns = window.galleryColumns || 3;
|
const numColumns = window.galleryColumns || 3;
|
||||||
galleryHTML += `
|
// clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
|
||||||
<div class="gallery-slider" style="margin:10px; text-align:center;">
|
const w = window.innerWidth;
|
||||||
<label for="galleryColumnsSlider" style="margin-right:5px;">
|
let maxCols = 6;
|
||||||
${t('columns')}:
|
if (w < 600) maxCols = 1;
|
||||||
</label>
|
else if (w < 900) maxCols = 2;
|
||||||
<input type="range" id="galleryColumnsSlider" min="1" max="6"
|
|
||||||
value="${numColumns}" style="vertical-align:middle;">
|
// ensure current value doesn’t exceed the new max
|
||||||
<span id="galleryColumnsValue">${numColumns}</span>
|
const startCols = Math.min(numColumns, maxCols);
|
||||||
</div>
|
window.galleryColumns = startCols;
|
||||||
`;
|
|
||||||
|
|
||||||
// --- Start gallery grid ---
|
// --- Start gallery grid ---
|
||||||
galleryHTML += `
|
galleryHTML += `
|
||||||
@@ -627,32 +882,52 @@ export function renderGalleryView(folder, container) {
|
|||||||
</span>
|
</span>
|
||||||
${tagBadgesHTML}
|
${tagBadgesHTML}
|
||||||
|
|
||||||
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
|
<div
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn"
|
class="btn-group btn-group-sm btn-group-hover"
|
||||||
data-download-name="${escapeHTML(file.name)}"
|
role="group"
|
||||||
data-download-folder="${file.folder || "root"}"
|
aria-label="File actions"
|
||||||
title="${t('download')}">
|
style="margin-top:5px;"
|
||||||
<i class="material-icons">file_download</i>
|
>
|
||||||
</button>
|
<button
|
||||||
${file.editable ? `
|
type="button"
|
||||||
<button type="button" class="btn btn-sm edit-btn"
|
class="btn btn-success py-1 download-btn"
|
||||||
data-edit-name="${escapeHTML(file.name)}"
|
data-download-name="${escapeHTML(file.name)}"
|
||||||
data-edit-folder="${file.folder || "root"}"
|
data-download-folder="${file.folder || "root"}"
|
||||||
title="${t('edit')}">
|
title="${t('download')}"
|
||||||
<i class="material-icons">edit</i>
|
>
|
||||||
</button>` : ""}
|
<i class="material-icons">file_download</i>
|
||||||
<button type="button" class="btn btn-sm btn-warning rename-btn"
|
</button>
|
||||||
data-rename-name="${escapeHTML(file.name)}"
|
|
||||||
data-rename-folder="${file.folder || "root"}"
|
${file.editable ? `
|
||||||
title="${t('rename')}">
|
<button
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
type="button"
|
||||||
</button>
|
class="btn btn-secondary py-1 edit-btn"
|
||||||
<button type="button" class="btn btn-sm btn-secondary share-btn"
|
data-edit-name="${escapeHTML(file.name)}"
|
||||||
data-file="${escapeHTML(file.name)}"
|
data-edit-folder="${file.folder || "root"}"
|
||||||
title="${t('share')}">
|
title="${t('edit')}"
|
||||||
<i class="material-icons">share</i>
|
>
|
||||||
</button>
|
<i class="material-icons">edit</i>
|
||||||
</div>
|
</button>` : ""}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning py-1 rename-btn"
|
||||||
|
data-rename-name="${escapeHTML(file.name)}"
|
||||||
|
data-rename-folder="${file.folder || "root"}"
|
||||||
|
title="${t('rename')}"
|
||||||
|
>
|
||||||
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary py-1 share-btn"
|
||||||
|
data-file="${escapeHTML(file.name)}"
|
||||||
|
title="${t('share')}"
|
||||||
|
>
|
||||||
|
<i class="material-icons">share</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -898,12 +1173,64 @@ function parseCustomDate(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canEditFile(fileName) {
|
export function canEditFile(fileName) {
|
||||||
|
if (!fileName || typeof fileName !== "string") return false;
|
||||||
|
const dot = fileName.lastIndexOf(".");
|
||||||
|
if (dot < 0) return false;
|
||||||
|
|
||||||
|
const ext = fileName.slice(dot + 1).toLowerCase();
|
||||||
|
|
||||||
|
// Text/code-only. Intentionally exclude php/phtml/phar/etc.
|
||||||
const allowedExtensions = [
|
const allowedExtensions = [
|
||||||
"txt", "html", "htm", "css", "js", "json", "xml",
|
// Plain text & docs (text)
|
||||||
"md", "py", "ini", "csv", "log", "conf", "config", "bat",
|
"txt", "text", "md", "markdown", "rst",
|
||||||
"rtf", "doc", "docx"
|
|
||||||
|
// Web
|
||||||
|
"html", "htm", "xhtml", "shtml",
|
||||||
|
"css", "scss", "sass", "less",
|
||||||
|
|
||||||
|
// JS/TS
|
||||||
|
"js", "mjs", "cjs", "jsx",
|
||||||
|
"ts", "tsx",
|
||||||
|
|
||||||
|
// Data & config formats
|
||||||
|
"json", "jsonc", "ndjson",
|
||||||
|
"yml", "yaml", "toml", "xml", "plist",
|
||||||
|
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
|
||||||
|
"env", "dotenv",
|
||||||
|
"csv", "tsv", "tab",
|
||||||
|
"log",
|
||||||
|
|
||||||
|
// Shell / scripts
|
||||||
|
"sh", "bash", "zsh", "ksh", "fish",
|
||||||
|
"bat", "cmd",
|
||||||
|
"ps1", "psm1", "psd1",
|
||||||
|
|
||||||
|
// Languages
|
||||||
|
"py", "pyw", // Python
|
||||||
|
"rb", // Ruby
|
||||||
|
"pl", "pm", // Perl
|
||||||
|
"go", // Go
|
||||||
|
"rs", // Rust
|
||||||
|
"java", // Java
|
||||||
|
"kt", "kts", // Kotlin
|
||||||
|
"scala", "sc", // Scala
|
||||||
|
"groovy", "gradle", // Groovy/Gradle
|
||||||
|
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", // C/C++
|
||||||
|
"m", "mm", // Obj-C / Obj-C++
|
||||||
|
"swift", // Swift
|
||||||
|
"cs", "fs", "fsx", // C#, F#
|
||||||
|
"dart",
|
||||||
|
"lua",
|
||||||
|
"r", "rmd",
|
||||||
|
|
||||||
|
// SQL
|
||||||
|
"sql",
|
||||||
|
|
||||||
|
// Front-end SFC/templates
|
||||||
|
"vue", "svelte",
|
||||||
|
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
|
||||||
];
|
];
|
||||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
|
||||||
return allowedExtensions.includes(ext);
|
return allowedExtensions.includes(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// fileMenu.js
|
// fileMenu.js
|
||||||
import { updateRowHighlight, showToast } from './domUtils.js';
|
import { updateRowHighlight, showToast } from './domUtils.js';
|
||||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js';
|
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
|
||||||
import { previewFile } from './filePreview.js';
|
import { previewFile } from './filePreview.js';
|
||||||
import { editFile } from './fileEditor.js';
|
import { editFile } from './fileEditor.js';
|
||||||
import { canEditFile, fileData } from './fileListView.js';
|
import { canEditFile, fileData } from './fileListView.js';
|
||||||
@@ -75,6 +75,7 @@ export function fileListContextMenuHandler(e) {
|
|||||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||||
|
|
||||||
let menuItems = [
|
let menuItems = [
|
||||||
|
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||||
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
||||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function saveFolderTreeState(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper for getting the parent folder.
|
// Helper for getting the parent folder.
|
||||||
function getParentFolder(folder) {
|
export function getParentFolder(folder) {
|
||||||
if (folder === "root") return "root";
|
if (folder === "root") return "root";
|
||||||
const lastSlash = folder.lastIndexOf("/");
|
const lastSlash = folder.lastIndexOf("/");
|
||||||
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
|
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
|
||||||
@@ -236,7 +236,8 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
|||||||
const state = loadFolderTreeState();
|
const state = loadFolderTreeState();
|
||||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||||
for (const folder in tree) {
|
for (const folder in tree) {
|
||||||
if (folder.toLowerCase() === "trash") continue;
|
const name = folder.toLowerCase();
|
||||||
|
if (name === "trash" || name === "profile_pics") continue;
|
||||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||||
@@ -360,7 +361,7 @@ function renderBreadcrumbFragment(folderPath) {
|
|||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBreadcrumbTitle(folder) {
|
export function updateBreadcrumbTitle(folder) {
|
||||||
const titleEl = document.getElementById("fileListTitle");
|
const titleEl = document.getElementById("fileListTitle");
|
||||||
titleEl.textContent = "";
|
titleEl.textContent = "";
|
||||||
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
||||||
@@ -550,7 +551,7 @@ export function loadFolderList(selectedFolder) {
|
|||||||
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
||||||
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
||||||
|
|
||||||
function openRenameFolderModal() {
|
export function openRenameFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") {
|
if (!selectedFolder || selectedFolder === "root") {
|
||||||
showToast("Please select a valid folder to rename.");
|
showToast("Please select a valid folder to rename.");
|
||||||
@@ -613,7 +614,7 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function openDeleteFolderModal() {
|
export function openDeleteFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") {
|
if (!selectedFolder || selectedFolder === "root") {
|
||||||
showToast("Please select a valid folder to delete.");
|
showToast("Please select a valid folder to delete.");
|
||||||
@@ -717,7 +718,7 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||||
function showFolderManagerContextMenu(x, y, menuItems) {
|
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||||
let menu = document.getElementById("folderManagerContextMenu");
|
let menu = document.getElementById("folderManagerContextMenu");
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
menu = document.createElement("div");
|
menu = document.createElement("div");
|
||||||
@@ -764,7 +765,7 @@ function showFolderManagerContextMenu(x, y, menuItems) {
|
|||||||
menu.style.display = "block";
|
menu.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideFolderManagerContextMenu() {
|
export function hideFolderManagerContextMenu() {
|
||||||
const menu = document.getElementById("folderManagerContextMenu");
|
const menu = document.getElementById("folderManagerContextMenu");
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.style.display = "none";
|
menu.style.display = "none";
|
||||||
@@ -795,7 +796,7 @@ function folderManagerContextMenuHandler(e) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("folder_share"),
|
label: t("folder_share"),
|
||||||
action: () => { openFolderShareModal(); }
|
action: () => { openFolderShareModal(folder); }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("delete_folder"),
|
label: t("delete_folder"),
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ const translations = {
|
|||||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||||
"admin_panel": "Admin Panel",
|
"admin_panel": "Admin Panel",
|
||||||
"user_panel": "User Panel",
|
"user_panel": "User Panel",
|
||||||
|
"user_settings": "User Settings",
|
||||||
|
"save_profile_picture": "Save Profile Picture",
|
||||||
|
"please_select_picture": "Please select a picture",
|
||||||
|
"profile_picture_updated": "Profile picture updated",
|
||||||
|
"error_updating_picture": "Error updating profile picture",
|
||||||
"trash_restore_delete": "Trash Restore/Delete",
|
"trash_restore_delete": "Trash Restore/Delete",
|
||||||
"totp_settings": "TOTP Settings",
|
"totp_settings": "TOTP Settings",
|
||||||
"enable_totp": "Enable TOTP",
|
"enable_totp": "Enable TOTP",
|
||||||
@@ -260,7 +265,17 @@ const translations = {
|
|||||||
"show": "Show",
|
"show": "Show",
|
||||||
"items_per_page": "items per page",
|
"items_per_page": "items per page",
|
||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
"api_docs": "API Docs"
|
"row_height": "Row Height",
|
||||||
|
"api_docs": "API Docs",
|
||||||
|
"show_folders_above_files": "Show folders above files",
|
||||||
|
"display": "Display",
|
||||||
|
"create_file": "Create File",
|
||||||
|
"create_new_file": "Create New File",
|
||||||
|
"enter_file_name": "Enter file name",
|
||||||
|
"newfile_placeholder": "New file name",
|
||||||
|
"file_created_successfully": "File created successfully!",
|
||||||
|
"error_creating_file": "Error creating file",
|
||||||
|
"file_created": "File created successfully!"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -15,9 +15,35 @@ import { editFile, saveFile } from './fileEditor.js';
|
|||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
|
|
||||||
export function initializeApp() {
|
export function initializeApp() {
|
||||||
|
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||||
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
|
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||||
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
|
const uploadArea = document.getElementById('uploadDropArea');
|
||||||
|
if (fileListArea && uploadArea) {
|
||||||
|
fileListArea.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileListArea.classList.add('drop-hover');
|
||||||
|
});
|
||||||
|
fileListArea.addEventListener('dragleave', () => {
|
||||||
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
});
|
||||||
|
fileListArea.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
// re-dispatch the same drop into the real upload card
|
||||||
|
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||||
|
dataTransfer: e.dataTransfer,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
loadSidebarOrder();
|
loadSidebarOrder();
|
||||||
loadHeaderOrder();
|
loadHeaderOrder();
|
||||||
@@ -27,46 +53,37 @@ export function initializeApp() {
|
|||||||
setupTrashRestoreDelete();
|
setupTrashRestoreDelete();
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
|
|
||||||
const helpBtn = document.getElementById("folderHelpBtn");
|
const helpBtn = document.getElementById("folderHelpBtn");
|
||||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||||
if (helpBtn && helpTooltip) {
|
if (helpBtn && helpTooltip) {
|
||||||
helpBtn.addEventListener("click", () => {
|
helpBtn.addEventListener("click", () => {
|
||||||
helpTooltip.style.display =
|
helpTooltip.style.display =
|
||||||
helpTooltip.style.display === "block" ? "none" : "block";
|
helpTooltip.style.display === "block" ? "none" : "block";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCsrfToken() {
|
export function loadCsrfToken() {
|
||||||
return fetchWithCsrf('/api/auth/token.php', {
|
return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
|
||||||
method: 'GET'
|
|
||||||
})
|
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
|
||||||
throw new Error(`Token fetch failed with status ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then(({ csrf_token, share_url }) => {
|
.then(({ csrf_token, share_url }) => {
|
||||||
// Update global and <meta>
|
|
||||||
window.csrfToken = csrf_token;
|
window.csrfToken = csrf_token;
|
||||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (!meta) {
|
// update CSRF meta
|
||||||
meta = document.createElement('meta');
|
let meta = document.querySelector('meta[name="csrf-token"]') ||
|
||||||
meta.name = 'csrf-token';
|
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
|
||||||
document.head.appendChild(meta);
|
|
||||||
}
|
|
||||||
meta.content = csrf_token;
|
meta.content = csrf_token;
|
||||||
|
|
||||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
// force share_url to match wherever we're browsing
|
||||||
if (!shareMeta) {
|
const actualShare = window.location.origin;
|
||||||
shareMeta = document.createElement('meta');
|
let shareMeta = document.querySelector('meta[name="share-url"]') ||
|
||||||
shareMeta.name = 'share-url';
|
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
|
||||||
document.head.appendChild(shareMeta);
|
shareMeta.content = actualShare;
|
||||||
}
|
|
||||||
shareMeta.content = share_url;
|
|
||||||
|
|
||||||
return { csrf_token, share_url };
|
return { csrf_token, share_url: actualShare };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,18 +94,14 @@ if (params.get('logout') === '1') {
|
|||||||
localStorage.removeItem("userTOTPEnabled");
|
localStorage.removeItem("userTOTPEnabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Wire up logoutBtn right away
|
export function triggerLogout() {
|
||||||
const logoutBtn = document.getElementById("logoutBtn");
|
fetch("/api/auth/logout.php", {
|
||||||
if (logoutBtn) {
|
method: "POST",
|
||||||
logoutBtn.addEventListener("click", () => {
|
credentials: "include",
|
||||||
fetch("/api/auth/logout.php", {
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
method: "POST",
|
})
|
||||||
credentials: "include",
|
.then(() => window.location.reload(true))
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
.catch(() => { });
|
||||||
})
|
|
||||||
.then(() => window.location.reload(true))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -122,9 +135,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
// Continue with initializations that rely on a valid CSRF token:
|
// Continue with initializations that rely on a valid CSRF token:
|
||||||
checkAuthentication().then(authenticated => {
|
checkAuthentication().then(authenticated => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
initializeApp();
|
initializeApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Other DOM initialization that can happen after CSRF is ready.
|
// Other DOM initialization that can happen after CSRF is ready.
|
||||||
@@ -201,7 +215,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Auto-scroll During Drag ---
|
// --- Auto-scroll During Drag ---
|
||||||
// Adjust these values as needed:
|
|
||||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
||||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
const SCROLL_SPEED = 20; // pixels to scroll per event
|
||||||
|
|
||||||
|
|||||||
@@ -79,15 +79,16 @@ export function setupTrashRestoreDelete() {
|
|||||||
body: JSON.stringify({ files })
|
body: JSON.stringify({ files })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(() => {
|
||||||
if (data.success) {
|
// Always report what we actually restored
|
||||||
showToast(data.success);
|
if (files.length === 1) {
|
||||||
toggleVisibility("restoreFilesModal", false);
|
showToast(`Restored file: ${files[0]}`);
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
loadFolderTree(window.currentFolder);
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error);
|
showToast(`Restored files: ${files.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
toggleVisibility("restoreFilesModal", false);
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
loadFolderTree(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
@@ -119,16 +120,15 @@ export function setupTrashRestoreDelete() {
|
|||||||
body: JSON.stringify({ files })
|
body: JSON.stringify({ files })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(() => {
|
||||||
if (data.success) {
|
if (files.length === 1) {
|
||||||
showToast(data.success);
|
showToast(`Restored file: ${files[0]}`);
|
||||||
toggleVisibility("restoreFilesModal", false);
|
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
loadFolderTree(window.currentFolder);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error);
|
showToast(`Restored files: ${files.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
toggleVisibility("restoreFilesModal", false);
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
loadFolderTree(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
|
|||||||
@@ -669,6 +669,18 @@ function submitFiles(allFiles) {
|
|||||||
}
|
}
|
||||||
allSucceeded = false;
|
allSucceeded = false;
|
||||||
}
|
}
|
||||||
|
if (file.isClipboard) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.selectedFiles = [];
|
||||||
|
updateFileInfoCount();
|
||||||
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
|
if (progressContainer) progressContainer.innerHTML = "";
|
||||||
|
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||||
|
if (fileInfoContainer) {
|
||||||
|
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this chunk as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
@@ -847,4 +859,39 @@ function initUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initUpload };
|
export { initUpload };
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Clipboard Paste Handler (Mimics Drag-and-Drop)
|
||||||
|
// -------------------------
|
||||||
|
document.addEventListener('paste', function handlePasteUpload(e) {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const ext = file.name.split('.').pop() || 'png';
|
||||||
|
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
|
||||||
|
renamedFile.isClipboard = true;
|
||||||
|
|
||||||
|
Object.defineProperty(renamedFile, 'customRelativePath', {
|
||||||
|
value: renamedFile.name,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
files.push(renamedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFiles(files);
|
||||||
|
showToast('Pasted file added to upload list.', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
143
scripts/scan_uploads.php
Normal file
143
scripts/scan_uploads.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* scan_uploads.php
|
||||||
|
* Rebuild/repair per-folder metadata used by FileRise models.
|
||||||
|
* - Uses UPLOAD_DIR / META_DIR / DATE_TIME_FORMAT from config.php
|
||||||
|
* - Per-folder metadata naming matches FileModel/FolderModel:
|
||||||
|
* "root" -> root_metadata.json
|
||||||
|
* "<sub/dir>" -> str_replace(['/', '\\', ' '], '-', '<sub/dir>') . '_metadata.json'
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
// ---------- helpers that mirror model behavior ----------
|
||||||
|
|
||||||
|
/** Compute the metadata JSON path for a folder key (e.g., "root", "invoices/2025"). */
|
||||||
|
function folder_metadata_path(string $folderKey): string {
|
||||||
|
if (strtolower(trim($folderKey)) === 'root' || trim($folderKey) === '') {
|
||||||
|
return rtrim(META_DIR, '/\\') . '/root_metadata.json';
|
||||||
|
}
|
||||||
|
$safe = str_replace(['/', '\\', ' '], '-', trim($folderKey));
|
||||||
|
return rtrim(META_DIR, '/\\') . '/' . $safe . '_metadata.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn an absolute path under UPLOAD_DIR into a folder key (“root” or relative with slashes). */
|
||||||
|
function to_folder_key(string $absPath): string {
|
||||||
|
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
if (realpath($absPath) === realpath(rtrim(UPLOAD_DIR, '/\\'))) {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
$rel = ltrim(str_replace('\\', '/', substr($absPath, strlen($base))), '/');
|
||||||
|
return $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List immediate files in a directory (no subdirs). */
|
||||||
|
function list_files(string $dir): array {
|
||||||
|
$out = [];
|
||||||
|
$entries = @scandir($dir);
|
||||||
|
if ($entries === false) return $out;
|
||||||
|
foreach ($entries as $name) {
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
$p = $dir . DIRECTORY_SEPARATOR . $name;
|
||||||
|
if (is_file($p)) $out[] = $name;
|
||||||
|
}
|
||||||
|
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively list subfolders (relative folder keys), skipping trash/. */
|
||||||
|
function list_all_folders(string $root): array {
|
||||||
|
$root = rtrim($root, '/\\');
|
||||||
|
$folders = ['root'];
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
foreach ($it as $path => $info) {
|
||||||
|
if ($info->isDir()) {
|
||||||
|
// relative key like "foo/bar"
|
||||||
|
$rel = ltrim(str_replace(['\\'], '/', substr($path, strlen($root) + 1)), '/');
|
||||||
|
if ($rel === '') continue;
|
||||||
|
// skip trash and profile_pics subtrees
|
||||||
|
if ($rel === 'trash' || strpos($rel, 'trash/') === 0) continue;
|
||||||
|
if ($rel === 'profile_pics' || strpos($rel, 'profile_pics/') === 0) continue;
|
||||||
|
// obey the app’s folder-name regex to stay consistent
|
||||||
|
if (preg_match(REGEX_FOLDER_NAME, basename($rel))) {
|
||||||
|
$folders[] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// de-dup and sort
|
||||||
|
$folders = array_values(array_unique($folders));
|
||||||
|
sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- main ----------
|
||||||
|
|
||||||
|
$uploads = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
$metaDir = rtrim(META_DIR, '/\\');
|
||||||
|
|
||||||
|
// Ensure metadata dir exists
|
||||||
|
if (!is_dir($metaDir)) {
|
||||||
|
@mkdir($metaDir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date(DATE_TIME_FORMAT);
|
||||||
|
$folders = list_all_folders($uploads);
|
||||||
|
|
||||||
|
$totalCreated = 0;
|
||||||
|
$totalPruned = 0;
|
||||||
|
|
||||||
|
foreach ($folders as $folderKey) {
|
||||||
|
$absFolder = ($folderKey === 'root')
|
||||||
|
? $uploads
|
||||||
|
: $uploads . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderKey);
|
||||||
|
|
||||||
|
if (!is_dir($absFolder)) continue;
|
||||||
|
|
||||||
|
$files = list_files($absFolder);
|
||||||
|
|
||||||
|
$metaPath = folder_metadata_path($folderKey);
|
||||||
|
$metadata = [];
|
||||||
|
if (is_file($metaPath)) {
|
||||||
|
$decoded = json_decode(@file_get_contents($metaPath), true);
|
||||||
|
if (is_array($decoded)) $metadata = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a quick lookup of existing entries
|
||||||
|
$existing = array_keys($metadata);
|
||||||
|
|
||||||
|
// ADD missing files
|
||||||
|
foreach ($files as $name) {
|
||||||
|
// Keep same filename validation used in FileModel
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $name)) continue;
|
||||||
|
|
||||||
|
if (!isset($metadata[$name])) {
|
||||||
|
$metadata[$name] = [
|
||||||
|
'uploaded' => $now,
|
||||||
|
'modified' => $now,
|
||||||
|
'uploader' => 'Imported'
|
||||||
|
];
|
||||||
|
$totalCreated++;
|
||||||
|
echo "Indexed: " . ($folderKey === 'root' ? '' : $folderKey . '/') . $name . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRUNE stale metadata entries for files that no longer exist
|
||||||
|
foreach ($existing as $name) {
|
||||||
|
if (!in_array($name, $files, true)) {
|
||||||
|
unset($metadata[$name]);
|
||||||
|
$totalPruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent dir exists and write metadata
|
||||||
|
@mkdir(dirname($metaPath), 0775, true);
|
||||||
|
if (@file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||||
|
fwrite(STDERR, "Failed to write metadata for folder: {$folderKey}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done. Created {$totalCreated} entr" . ($totalCreated === 1 ? "y" : "ies") .
|
||||||
|
", pruned {$totalPruned}.\n";
|
||||||
@@ -150,7 +150,7 @@ class AdminController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Invalid CSRF token.']);
|
echo json_encode(['error' => 'Invalid CSRF token.']);
|
||||||
@@ -180,7 +180,7 @@ class AdminController
|
|||||||
$merged['loginOptions'] = $existing['loginOptions'] ?? [
|
$merged['loginOptions'] = $existing['loginOptions'] ?? [
|
||||||
'disableFormLogin' => false,
|
'disableFormLogin' => false,
|
||||||
'disableBasicAuth' => false,
|
'disableBasicAuth' => false,
|
||||||
'disableOIDCLogin'=> false,
|
'disableOIDCLogin'=> true,
|
||||||
'authBypass' => false,
|
'authBypass' => false,
|
||||||
'authHeaderName' => 'X-Remote-User'
|
'authHeaderName' => 'X-Remote-User'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1626,4 +1626,31 @@ class FileController
|
|||||||
echo json_encode(['success' => false, 'error' => 'Not found']);
|
echo json_encode(['success' => false, 'error' => 'Not found']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/file/createFile.php
|
||||||
|
*/
|
||||||
|
public function createFile(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
// Check user permissions (assuming loadUserPermissions() is available).
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$userPermissions = loadUserPermissions($username);
|
||||||
|
if (!empty($userPermissions['readOnly'])) {
|
||||||
|
echo json_encode(["error" => "Read-only users are not allowed to create files."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$folder = $body['folder'] ?? 'root';
|
||||||
|
$filename = $body['name'] ?? '';
|
||||||
|
|
||||||
|
$result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown');
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
http_response_code($result['code'] ?? 400);
|
||||||
|
echo json_encode(['success'=>false,'error'=>$result['error']]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success'=>true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,12 +96,14 @@ class FolderController
|
|||||||
|
|
||||||
// Basic sanitation for folderName.
|
// Basic sanitation for folderName.
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Invalid folder name.']);
|
echo json_encode(['error' => 'Invalid folder name.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally sanitize the parent.
|
// Optionally sanitize the parent.
|
||||||
if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Invalid parent folder name.']);
|
echo json_encode(['error' => 'Invalid parent folder name.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -340,16 +342,14 @@ class FolderController
|
|||||||
public function getFolderList(): void
|
public function getFolderList(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
// Ensure user is authenticated.
|
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
echo json_encode(["error" => "Unauthorized"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally, you might add further input validation if necessary.
|
$parent = $_GET['folder'] ?? null;
|
||||||
$folderList = FolderModel::getFolderList();
|
$folderList = FolderModel::getFolderList($parent);
|
||||||
echo json_encode($folderList);
|
echo json_encode($folderList);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -1087,11 +1087,11 @@ class FolderController
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
$shareFile = META_DIR . 'share_folder_links.json';
|
$shareFile = META_DIR . 'share_folder_links.json';
|
||||||
$links = file_exists($shareFile)
|
$links = file_exists($shareFile)
|
||||||
? json_decode(file_get_contents($shareFile), true) ?? []
|
? json_decode(file_get_contents($shareFile), true) ?? []
|
||||||
: [];
|
: [];
|
||||||
$now = time();
|
$now = time();
|
||||||
$cleaned = [];
|
$cleaned = [];
|
||||||
|
|
||||||
// 1) Remove expired
|
// 1) Remove expired
|
||||||
foreach ($links as $token => $record) {
|
foreach ($links as $token => $record) {
|
||||||
if (!empty($record['expires']) && $record['expires'] < $now) {
|
if (!empty($record['expires']) && $record['expires'] < $now) {
|
||||||
@@ -1099,12 +1099,12 @@ class FolderController
|
|||||||
}
|
}
|
||||||
$cleaned[$token] = $record;
|
$cleaned[$token] = $record;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Persist back if anything was pruned
|
// 2) Persist back if anything was pruned
|
||||||
if (count($cleaned) !== count($links)) {
|
if (count($cleaned) !== count($links)) {
|
||||||
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
|
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode($cleaned);
|
echo json_encode($cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -867,123 +867,126 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function verifyTOTP()
|
public function verifyTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||||
|
|
||||||
// Rate-limit
|
// Rate-limit
|
||||||
if (!isset($_SESSION['totp_failures'])) {
|
if (!isset($_SESSION['totp_failures'])) {
|
||||||
$_SESSION['totp_failures'] = 0;
|
$_SESSION['totp_failures'] = 0;
|
||||||
}
|
}
|
||||||
if ($_SESSION['totp_failures'] >= 5) {
|
if ($_SESSION['totp_failures'] >= 5) {
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be authenticated OR pending login
|
// Must be authenticated OR pending login
|
||||||
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
||||||
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse & validate input
|
// Parse & validate input
|
||||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
$inputData = json_decode(file_get_contents("php://input"), true);
|
||||||
$code = trim($inputData['totp_code'] ?? '');
|
$code = trim($inputData['totp_code'] ?? '');
|
||||||
if (!preg_match('/^\d{6}$/', $code)) {
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TFA helper
|
// TFA helper
|
||||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
||||||
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
|
'FileRise',
|
||||||
);
|
6,
|
||||||
|
30,
|
||||||
// === Pending-login flow (we just came from auth and need to finish login) ===
|
\RobThree\Auth\Algorithm::Sha1
|
||||||
if (isset($_SESSION['pending_login_user'])) {
|
);
|
||||||
$username = $_SESSION['pending_login_user'];
|
|
||||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
// === Pending-login flow (we just came from auth and need to finish login) ===
|
||||||
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
if (isset($_SESSION['pending_login_user'])) {
|
||||||
|
$username = $_SESSION['pending_login_user'];
|
||||||
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
||||||
$_SESSION['totp_failures']++;
|
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
||||||
exit;
|
$_SESSION['totp_failures']++;
|
||||||
}
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
||||||
// Issue “remember me” token if requested
|
exit;
|
||||||
if ($rememberMe) {
|
}
|
||||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
|
||||||
$token = bin2hex(random_bytes(32));
|
// Issue “remember me” token if requested
|
||||||
$expiry = time() + 30 * 24 * 60 * 60;
|
if ($rememberMe) {
|
||||||
$all = [];
|
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||||
if (file_exists($tokFile)) {
|
$token = bin2hex(random_bytes(32));
|
||||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
$expiry = time() + 30 * 24 * 60 * 60;
|
||||||
$all = json_decode($dec, true) ?: [];
|
$all = [];
|
||||||
}
|
if (file_exists($tokFile)) {
|
||||||
$all[$token] = [
|
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||||
'username' => $username,
|
$all = json_decode($dec, true) ?: [];
|
||||||
'expiry' => $expiry,
|
}
|
||||||
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
$all[$token] = [
|
||||||
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
'username' => $username,
|
||||||
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
'expiry' => $expiry,
|
||||||
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false
|
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
||||||
];
|
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
||||||
file_put_contents(
|
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
||||||
$tokFile,
|
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
|
||||||
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
];
|
||||||
LOCK_EX
|
file_put_contents(
|
||||||
);
|
$tokFile,
|
||||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
||||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
LOCK_EX
|
||||||
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
);
|
||||||
}
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||||
// === Finalize login into session exactly as finalizeLogin() would ===
|
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
||||||
session_regenerate_id(true);
|
}
|
||||||
$_SESSION['authenticated'] = true;
|
|
||||||
$_SESSION['username'] = $username;
|
// === Finalize login into session exactly as finalizeLogin() would ===
|
||||||
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
session_regenerate_id(true);
|
||||||
$perms = loadUserPermissions($username);
|
$_SESSION['authenticated'] = true;
|
||||||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
$_SESSION['username'] = $username;
|
||||||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
||||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
$perms = loadUserPermissions($username);
|
||||||
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||||
// Clean up pending markers
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||||
unset(
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
$_SESSION['pending_login_user'],
|
|
||||||
$_SESSION['pending_login_secret'],
|
// Clean up pending markers
|
||||||
$_SESSION['pending_login_remember_me'],
|
unset(
|
||||||
$_SESSION['totp_failures']
|
$_SESSION['pending_login_user'],
|
||||||
);
|
$_SESSION['pending_login_secret'],
|
||||||
|
$_SESSION['pending_login_remember_me'],
|
||||||
// Send back full login payload
|
$_SESSION['totp_failures']
|
||||||
echo json_encode([
|
);
|
||||||
'status' => 'ok',
|
|
||||||
'success' => 'Login successful',
|
// Send back full login payload
|
||||||
'isAdmin' => $_SESSION['isAdmin'],
|
echo json_encode([
|
||||||
'folderOnly' => $_SESSION['folderOnly'],
|
'status' => 'ok',
|
||||||
'readOnly' => $_SESSION['readOnly'],
|
'success' => 'Login successful',
|
||||||
'disableUpload' => $_SESSION['disableUpload'],
|
'isAdmin' => $_SESSION['isAdmin'],
|
||||||
'username' => $_SESSION['username']
|
'folderOnly' => $_SESSION['folderOnly'],
|
||||||
]);
|
'readOnly' => $_SESSION['readOnly'],
|
||||||
exit;
|
'disableUpload' => $_SESSION['disableUpload'],
|
||||||
}
|
'username' => $_SESSION['username']
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Setup/verification flow (not pending)
|
// Setup/verification flow (not pending)
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
@@ -1011,4 +1014,91 @@ class UserController
|
|||||||
unset($_SESSION['totp_failures']);
|
unset($_SESSION['totp_failures']);
|
||||||
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function uploadPicture()
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// 1) Auth check
|
||||||
|
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) CSRF check
|
||||||
|
$headers = function_exists('getallheaders')
|
||||||
|
? array_change_key_case(getallheaders(), CASE_LOWER)
|
||||||
|
: [];
|
||||||
|
$csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) File presence
|
||||||
|
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$file = $_FILES['profile_picture'];
|
||||||
|
|
||||||
|
// 4) Validate MIME & size
|
||||||
|
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||||
|
finfo_close($finfo);
|
||||||
|
if (!isset($allowed[$mime])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ($file['size'] > 2 * 1024 * 1024) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'File too large']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Destination under public/uploads/profile_pics
|
||||||
|
$uploadDir = UPLOAD_DIR . '/profile_pics';
|
||||||
|
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Move file
|
||||||
|
$ext = $allowed[$mime];
|
||||||
|
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
|
||||||
|
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
$dest = "$uploadDir/$filename";
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Build public URL
|
||||||
|
$url = '/uploads/profile_pics/' . $filename;
|
||||||
|
|
||||||
|
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
|
||||||
|
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
|
||||||
|
if (!$result['success']) {
|
||||||
|
// on failure, remove the file we just wrote
|
||||||
|
@unlink($dest);
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Failed to save profile picture setting'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 8) Return success
|
||||||
|
echo json_encode(['success' => true, 'url' => $url]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,19 @@ class AdminModel
|
|||||||
*/
|
*/
|
||||||
public static function updateConfig(array $configUpdate): array
|
public static function updateConfig(array $configUpdate): array
|
||||||
{
|
{
|
||||||
// Validate required OIDC configuration keys.
|
// New: only enforce OIDC fields when OIDC is enabled
|
||||||
if (
|
$oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
|
||||||
empty($configUpdate['oidc']['providerUrl']) ||
|
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
|
||||||
empty($configUpdate['oidc']['clientId']) ||
|
: true; // default to disabled when not present
|
||||||
empty($configUpdate['oidc']['clientSecret']) ||
|
|
||||||
empty($configUpdate['oidc']['redirectUri'])
|
if (!$oidcDisabled) {
|
||||||
) {
|
$oidc = $configUpdate['oidc'] ?? [];
|
||||||
return ["error" => "Incomplete OIDC configuration."];
|
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
|
||||||
|
foreach ($required as $k) {
|
||||||
|
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
|
||||||
|
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure enableWebDAV flag is boolean (default to false if missing)
|
// Ensure enableWebDAV flag is boolean (default to false if missing)
|
||||||
@@ -111,6 +116,8 @@ class AdminModel
|
|||||||
return ["error" => "Failed to update configuration even after cleanup."];
|
return ["error" => "Failed to update configuration even after cleanup."];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Best-effort normalize perms for host visibility (user rw, group rw)
|
||||||
|
@chmod($configFile, 0664);
|
||||||
|
|
||||||
return ["success" => "Configuration updated successfully."];
|
return ["success" => "Configuration updated successfully."];
|
||||||
}
|
}
|
||||||
@@ -137,17 +144,34 @@ class AdminModel
|
|||||||
|
|
||||||
// Normalize login options if missing
|
// Normalize login options if missing
|
||||||
if (!isset($config['loginOptions'])) {
|
if (!isset($config['loginOptions'])) {
|
||||||
|
// migrate legacy top-level flags; default OIDC to true (disabled)
|
||||||
$config['loginOptions'] = [
|
$config['loginOptions'] = [
|
||||||
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
||||||
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
||||||
'disableOIDCLogin' => isset($config['disableOIDCLogin']) ? (bool)$config['disableOIDCLogin'] : false,
|
'disableOIDCLogin' => isset($config['disableOIDCLogin']) ? (bool)$config['disableOIDCLogin'] : true,
|
||||||
];
|
];
|
||||||
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
|
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
|
||||||
} else {
|
} else {
|
||||||
// Ensure proper boolean types
|
// normalize booleans; default OIDC to true (disabled) if missing
|
||||||
$config['loginOptions']['disableFormLogin'] = (bool)$config['loginOptions']['disableFormLogin'];
|
$lo = &$config['loginOptions'];
|
||||||
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
|
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
|
||||||
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
|
$lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false;
|
||||||
|
$lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($config['oidc']) || !is_array($config['oidc'])) {
|
||||||
|
$config['oidc'] = [
|
||||||
|
'providerUrl' => '',
|
||||||
|
'clientId' => '',
|
||||||
|
'clientSecret' => '',
|
||||||
|
'redirectUri' => '',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $k) {
|
||||||
|
if (!isset($config['oidc'][$k]) || !is_string($config['oidc'][$k])) {
|
||||||
|
$config['oidc'][$k] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
||||||
@@ -193,7 +217,7 @@ class AdminModel
|
|||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
'disableFormLogin' => false,
|
'disableFormLogin' => false,
|
||||||
'disableBasicAuth' => false,
|
'disableBasicAuth' => false,
|
||||||
'disableOIDCLogin' => false
|
'disableOIDCLogin' => true
|
||||||
],
|
],
|
||||||
'globalOtpauthUrl' => "",
|
'globalOtpauthUrl' => "",
|
||||||
'enableWebDAV' => false,
|
'enableWebDAV' => false,
|
||||||
|
|||||||
@@ -1167,19 +1167,24 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
* @return array Returns an associative array with keys "files" and "globalTags".
|
* @return array Returns an associative array with keys "files" and "globalTags".
|
||||||
*/
|
*/
|
||||||
public static function getFileList(string $folder): array {
|
public static function getFileList(string $folder): array {
|
||||||
|
// --- caps for safe inlining ---
|
||||||
|
if (!defined('LISTING_CONTENT_BYTES_MAX')) define('LISTING_CONTENT_BYTES_MAX', 8192); // 8 KB snippet
|
||||||
|
if (!defined('INDEX_TEXT_BYTES_MAX')) define('INDEX_TEXT_BYTES_MAX', 5 * 1024 * 1024); // only sample files ≤ 5 MB
|
||||||
|
|
||||||
$folder = trim($folder) ?: 'root';
|
$folder = trim($folder) ?: 'root';
|
||||||
|
|
||||||
// Determine the target directory.
|
// Determine the target directory.
|
||||||
if (strtolower($folder) !== 'root') {
|
if (strtolower($folder) !== 'root') {
|
||||||
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||||
} else {
|
} else {
|
||||||
$directory = UPLOAD_DIR;
|
$directory = UPLOAD_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate folder.
|
// Validate folder.
|
||||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||||
return ["error" => "Invalid folder name."];
|
return ["error" => "Invalid folder name."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Build the metadata file path.
|
// Helper: Build the metadata file path.
|
||||||
$getMetadataFilePath = function(string $folder): string {
|
$getMetadataFilePath = function(string $folder): string {
|
||||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||||
@@ -1188,23 +1193,26 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||||
};
|
};
|
||||||
$metadataFile = $getMetadataFilePath($folder);
|
$metadataFile = $getMetadataFilePath($folder);
|
||||||
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
$metadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||||
|
|
||||||
if (!is_dir($directory)) {
|
if (!is_dir($directory)) {
|
||||||
return ["error" => "Directory not found."];
|
return ["error" => "Directory not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
$allFiles = array_values(array_diff(scandir($directory), array('.', '..')));
|
$allFiles = array_values(array_diff(scandir($directory), array('.', '..')));
|
||||||
$fileList = [];
|
$fileList = [];
|
||||||
|
|
||||||
// Define a safe file name pattern.
|
// Define a safe file name pattern.
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
|
||||||
|
// Prepare finfo (if available) for MIME sniffing.
|
||||||
|
$finfo = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
|
||||||
|
|
||||||
foreach ($allFiles as $file) {
|
foreach ($allFiles as $file) {
|
||||||
if (substr($file, 0, 1) === '.') {
|
if ($file === '' || $file[0] === '.') {
|
||||||
continue; // Skip hidden files.
|
continue; // Skip hidden/invalid entries.
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||||
if (!is_file($filePath)) {
|
if (!is_file($filePath)) {
|
||||||
continue; // Only process files.
|
continue; // Only process files.
|
||||||
@@ -1212,13 +1220,17 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
if (!preg_match($safeFileNamePattern, $file)) {
|
if (!preg_match($safeFileNamePattern, $file)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown";
|
// Meta
|
||||||
|
$mtime = @filemtime($filePath);
|
||||||
|
$fileDateModified = $mtime ? date(DATE_TIME_FORMAT, $mtime) : "Unknown";
|
||||||
$metaKey = $file;
|
$metaKey = $file;
|
||||||
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
||||||
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
||||||
|
|
||||||
$fileSizeBytes = filesize($filePath);
|
// Size
|
||||||
|
$fileSizeBytes = @filesize($filePath);
|
||||||
|
if (!is_int($fileSizeBytes)) $fileSizeBytes = 0;
|
||||||
if ($fileSizeBytes >= 1073741824) {
|
if ($fileSizeBytes >= 1073741824) {
|
||||||
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
|
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
|
||||||
} elseif ($fileSizeBytes >= 1048576) {
|
} elseif ($fileSizeBytes >= 1048576) {
|
||||||
@@ -1228,29 +1240,65 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
} else {
|
} else {
|
||||||
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileEntry = [
|
// MIME + text detection (fallback to extension)
|
||||||
'name' => $file,
|
$mime = 'application/octet-stream';
|
||||||
'modified' => $fileDateModified,
|
if ($finfo) {
|
||||||
'uploaded' => $fileUploadedDate,
|
$det = @finfo_file($finfo, $filePath);
|
||||||
'size' => $fileSizeFormatted,
|
if (is_string($det) && $det !== '') $mime = $det;
|
||||||
'uploader' => $fileUploader,
|
|
||||||
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
|
|
||||||
];
|
|
||||||
|
|
||||||
// Optionally include file content for text-based files.
|
|
||||||
if (preg_match('/\.(txt|html|htm|md|js|css|json|xml|php|py|ini|conf|log)$/i', $file)) {
|
|
||||||
$content = file_get_contents($filePath);
|
|
||||||
$fileEntry['content'] = $content;
|
|
||||||
}
|
}
|
||||||
|
$isTextByMime = (strpos((string)$mime, 'text/') === 0) || $mime === 'application/json' || $mime === 'application/xml';
|
||||||
|
$isTextByExt = (bool)preg_match('/\.(txt|md|csv|json|xml|html?|css|js|log|ini|conf|config|yml|yaml|php|py|rb|sh|bat|ps1|ts|tsx|c|cpp|h|hpp|java|go|rs)$/i', $file);
|
||||||
|
$isText = $isTextByMime || $isTextByExt;
|
||||||
|
|
||||||
|
// Build entry
|
||||||
|
$fileEntry = [
|
||||||
|
'name' => $file,
|
||||||
|
'modified' => $fileDateModified,
|
||||||
|
'uploaded' => $fileUploadedDate,
|
||||||
|
'size' => $fileSizeFormatted,
|
||||||
|
'sizeBytes' => $fileSizeBytes, // ← numeric size for frontend logic
|
||||||
|
'uploader' => $fileUploader,
|
||||||
|
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [],
|
||||||
|
'mime' => $mime,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Small, safe snippet for text files only (never full content)
|
||||||
|
$fileEntry['content'] = '';
|
||||||
|
$fileEntry['contentTruncated'] = false;
|
||||||
|
|
||||||
|
if ($isText && $fileSizeBytes > 0) {
|
||||||
|
if ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) {
|
||||||
|
$fh = @fopen($filePath, 'rb');
|
||||||
|
if ($fh) {
|
||||||
|
$snippet = @fread($fh, LISTING_CONTENT_BYTES_MAX);
|
||||||
|
@fclose($fh);
|
||||||
|
if ($snippet !== false) {
|
||||||
|
// ensure UTF-8 for JSON
|
||||||
|
if (function_exists('mb_check_encoding') && !mb_check_encoding($snippet, 'UTF-8')) {
|
||||||
|
if (function_exists('mb_convert_encoding')) {
|
||||||
|
$snippet = @mb_convert_encoding($snippet, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fileEntry['content'] = $snippet;
|
||||||
|
$fileEntry['contentTruncated'] = ($fileSizeBytes > LISTING_CONTENT_BYTES_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// too large to sample: mark truncated so UI/search knows
|
||||||
|
$fileEntry['contentTruncated'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$fileList[] = $fileEntry;
|
$fileList[] = $fileEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($finfo) { @finfo_close($finfo); }
|
||||||
|
|
||||||
// Load global tags.
|
// Load global tags.
|
||||||
$globalTagsFile = META_DIR . "createdTags.json";
|
$globalTagsFile = META_DIR . "createdTags.json";
|
||||||
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
|
$globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : [];
|
||||||
|
|
||||||
return ["files" => $fileList, "globalTags" => $globalTags];
|
return ["files" => $fileList, "globalTags" => $globalTags];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,4 +1326,64 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
|
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty file plus metadata entry.
|
||||||
|
*
|
||||||
|
* @param string $folder
|
||||||
|
* @param string $filename
|
||||||
|
* @param string $uploader
|
||||||
|
* @return array ['success'=>bool, 'error'=>string, 'code'=>int]
|
||||||
|
*/
|
||||||
|
public static function createFile(string $folder, string $filename, string $uploader): array
|
||||||
|
{
|
||||||
|
// 1) basic validation
|
||||||
|
if (!preg_match('/^[\w\-. ]+$/', $filename)) {
|
||||||
|
return ['success'=>false,'error'=>'Invalid filename','code'=>400];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) build target path
|
||||||
|
$base = UPLOAD_DIR;
|
||||||
|
if ($folder !== 'root') {
|
||||||
|
$base = rtrim(UPLOAD_DIR, '/\\')
|
||||||
|
. DIRECTORY_SEPARATOR . $folder
|
||||||
|
. DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
if (!is_dir($base) && !mkdir($base, 0775, true)) {
|
||||||
|
return ['success'=>false,'error'=>'Cannot create folder','code'=>500];
|
||||||
|
}
|
||||||
|
$path = $base . $filename;
|
||||||
|
|
||||||
|
// 3) no overwrite
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return ['success'=>false,'error'=>'File already exists','code'=>400];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) touch the file
|
||||||
|
if (false === @file_put_contents($path, '')) {
|
||||||
|
return ['success'=>false,'error'=>'Could not create file','code'=>500];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) write metadata
|
||||||
|
$metaKey = ($folder === 'root') ? 'root' : $folder;
|
||||||
|
$metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json';
|
||||||
|
$metaPath = META_DIR . $metaName;
|
||||||
|
|
||||||
|
$collection = [];
|
||||||
|
if (file_exists($metaPath)) {
|
||||||
|
$json = file_get_contents($metaPath);
|
||||||
|
$collection = json_decode($json, true) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$collection[$filename] = [
|
||||||
|
'uploaded' => date(DATE_TIME_FORMAT),
|
||||||
|
'uploader' => $uploader
|
||||||
|
];
|
||||||
|
|
||||||
|
if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT))) {
|
||||||
|
return ['success'=>false,'error'=>'Failed to update metadata','code'=>500];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success'=>true];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
class userModel {
|
class userModel
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Retrieves all users from the users file.
|
* Retrieves all users from the users file.
|
||||||
*
|
*
|
||||||
* @return array Returns an array of users.
|
* @return array Returns an array of users.
|
||||||
*/
|
*/
|
||||||
public static function getAllUsers() {
|
public static function getAllUsers()
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
$users = [];
|
$users = [];
|
||||||
if (file_exists($usersFile)) {
|
if (file_exists($usersFile)) {
|
||||||
@@ -26,7 +28,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new user.
|
* Adds a new user.
|
||||||
*
|
*
|
||||||
@@ -36,14 +38,15 @@ class userModel {
|
|||||||
* @param bool $setupMode If true, overwrite the users file.
|
* @param bool $setupMode If true, overwrite the users file.
|
||||||
* @return array Response containing either an error or a success message.
|
* @return array Response containing either an error or a success message.
|
||||||
*/
|
*/
|
||||||
public static function addUser($username, $password, $isAdmin, $setupMode) {
|
public static function addUser($username, $password, $isAdmin, $setupMode)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
// Ensure users.txt exists.
|
// Ensure users.txt exists.
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
file_put_contents($usersFile, '');
|
file_put_contents($usersFile, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if username already exists.
|
// Check if username already exists.
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
foreach ($existingUsers as $line) {
|
foreach ($existingUsers as $line) {
|
||||||
@@ -52,40 +55,41 @@ class userModel {
|
|||||||
return ["error" => "User already exists"];
|
return ["error" => "User already exists"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the password.
|
// Hash the password.
|
||||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
// Prepare the new line.
|
// Prepare the new line.
|
||||||
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||||
|
|
||||||
// If setup mode, overwrite the file; otherwise, append.
|
// If setup mode, overwrite the file; otherwise, append.
|
||||||
if ($setupMode) {
|
if ($setupMode) {
|
||||||
file_put_contents($usersFile, $newUserLine);
|
file_put_contents($usersFile, $newUserLine);
|
||||||
} else {
|
} else {
|
||||||
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User added successfully"];
|
return ["success" => "User added successfully"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the specified user from the users file and updates the userPermissions file.
|
* Removes the specified user from the users file and updates the userPermissions file.
|
||||||
*
|
*
|
||||||
* @param string $usernameToRemove The username to remove.
|
* @param string $usernameToRemove The username to remove.
|
||||||
* @return array An array with either an error message or a success message.
|
* @return array An array with either an error message or a success message.
|
||||||
*/
|
*/
|
||||||
public static function removeUser($usernameToRemove) {
|
public static function removeUser($usernameToRemove)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$newUsers = [];
|
$newUsers = [];
|
||||||
$userFound = false;
|
$userFound = false;
|
||||||
|
|
||||||
// Loop through users; skip (remove) the specified user.
|
// Loop through users; skip (remove) the specified user.
|
||||||
foreach ($existingUsers as $line) {
|
foreach ($existingUsers as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
@@ -98,14 +102,14 @@ class userModel {
|
|||||||
}
|
}
|
||||||
$newUsers[] = $line;
|
$newUsers[] = $line;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$userFound) {
|
if (!$userFound) {
|
||||||
return ["error" => "User not found"];
|
return ["error" => "User not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the updated user list back to the file.
|
// Write the updated user list back to the file.
|
||||||
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
||||||
|
|
||||||
// Update the userPermissions.json file.
|
// Update the userPermissions.json file.
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
if (file_exists($permissionsFile)) {
|
if (file_exists($permissionsFile)) {
|
||||||
@@ -116,18 +120,19 @@ class userModel {
|
|||||||
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
|
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User removed successfully"];
|
return ["success" => "User removed successfully"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves permissions from the userPermissions.json file.
|
* Retrieves permissions from the userPermissions.json file.
|
||||||
* If the current user is an admin, returns all permissions.
|
* If the current user is an admin, returns all permissions.
|
||||||
* Otherwise, returns only the permissions for the current user.
|
* Otherwise, returns only the permissions for the current user.
|
||||||
*
|
*
|
||||||
* @return array|object Returns an associative array of permissions or an empty object if none are found.
|
* @return array|object Returns an associative array of permissions or an empty object if none are found.
|
||||||
*/
|
*/
|
||||||
public static function getUserPermissions() {
|
public static function getUserPermissions()
|
||||||
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
$permissionsArray = [];
|
$permissionsArray = [];
|
||||||
@@ -165,13 +170,14 @@ class userModel {
|
|||||||
return new stdClass();
|
return new stdClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates user permissions in the userPermissions.json file.
|
* Updates user permissions in the userPermissions.json file.
|
||||||
*
|
*
|
||||||
* @param array $permissions An array of permission updates.
|
* @param array $permissions An array of permission updates.
|
||||||
* @return array An associative array with a success or error message.
|
* @return array An associative array with a success or error message.
|
||||||
*/
|
*/
|
||||||
public static function updateUserPermissions($permissions) {
|
public static function updateUserPermissions($permissions)
|
||||||
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
$existingPermissions = [];
|
$existingPermissions = [];
|
||||||
@@ -185,7 +191,7 @@ class userModel {
|
|||||||
$existingPermissions = [];
|
$existingPermissions = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user roles from the users file.
|
// Load user roles from the users file.
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
$userRoles = [];
|
$userRoles = [];
|
||||||
@@ -199,7 +205,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each permission update.
|
// Process each permission update.
|
||||||
foreach ($permissions as $perm) {
|
foreach ($permissions as $perm) {
|
||||||
if (!isset($perm['username'])) {
|
if (!isset($perm['username'])) {
|
||||||
@@ -208,12 +214,12 @@ class userModel {
|
|||||||
$username = $perm['username'];
|
$username = $perm['username'];
|
||||||
// Look up the user's role.
|
// Look up the user's role.
|
||||||
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
||||||
|
|
||||||
// Skip updating permissions for admin users.
|
// Skip updating permissions for admin users.
|
||||||
if ($role === "1") {
|
if ($role === "1") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update permissions: default any missing value to false.
|
// Update permissions: default any missing value to false.
|
||||||
$existingPermissions[strtolower($username)] = [
|
$existingPermissions[strtolower($username)] = [
|
||||||
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
||||||
@@ -221,7 +227,7 @@ class userModel {
|
|||||||
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the updated permissions array to JSON.
|
// Convert the updated permissions array to JSON.
|
||||||
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||||
// Encrypt the JSON.
|
// Encrypt the JSON.
|
||||||
@@ -231,11 +237,11 @@ class userModel {
|
|||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return ["error" => "Failed to save user permissions."];
|
return ["error" => "Failed to save user permissions."];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User permissions updated successfully."];
|
return ["success" => "User permissions updated successfully."];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the password for the given user.
|
* Changes the password for the given user.
|
||||||
*
|
*
|
||||||
* @param string $username The username whose password is to be changed.
|
* @param string $username The username whose password is to be changed.
|
||||||
@@ -243,17 +249,18 @@ class userModel {
|
|||||||
* @param string $newPassword The new password.
|
* @param string $newPassword The new password.
|
||||||
* @return array An array with either a success or error message.
|
* @return array An array with either a success or error message.
|
||||||
*/
|
*/
|
||||||
public static function changePassword($username, $oldPassword, $newPassword) {
|
public static function changePassword($username, $oldPassword, $newPassword)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$userFound = false;
|
$userFound = false;
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Expect at least 3 parts: username, hashed password, and role.
|
// Expect at least 3 parts: username, hashed password, and role.
|
||||||
@@ -266,7 +273,7 @@ class userModel {
|
|||||||
$storedRole = $parts[2];
|
$storedRole = $parts[2];
|
||||||
// Preserve TOTP secret if it exists.
|
// Preserve TOTP secret if it exists.
|
||||||
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
||||||
|
|
||||||
if ($storedUser === $username) {
|
if ($storedUser === $username) {
|
||||||
$userFound = true;
|
$userFound = true;
|
||||||
// Verify the old password.
|
// Verify the old password.
|
||||||
@@ -275,7 +282,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
// Hash the new password.
|
// Hash the new password.
|
||||||
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
// Rebuild the line, preserving TOTP secret if it exists.
|
// Rebuild the line, preserving TOTP secret if it exists.
|
||||||
if ($totpSecret !== "") {
|
if ($totpSecret !== "") {
|
||||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
||||||
@@ -286,11 +293,11 @@ class userModel {
|
|||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$userFound) {
|
if (!$userFound) {
|
||||||
return ["error" => "User not found."];
|
return ["error" => "User not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated users file.
|
// Save the updated users file.
|
||||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
||||||
return ["success" => "Password updated successfully."];
|
return ["success" => "Password updated successfully."];
|
||||||
@@ -299,25 +306,26 @@ class userModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled.
|
* Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled.
|
||||||
*
|
*
|
||||||
* @param string $username The username whose panel settings are being updated.
|
* @param string $username The username whose panel settings are being updated.
|
||||||
* @param bool $totp_enabled Whether TOTP is enabled.
|
* @param bool $totp_enabled Whether TOTP is enabled.
|
||||||
* @return array An array indicating success or failure.
|
* @return array An array indicating success or failure.
|
||||||
*/
|
*/
|
||||||
public static function updateUserPanel($username, $totp_enabled) {
|
public static function updateUserPanel($username, $totp_enabled)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TOTP is disabled, update the file to clear the TOTP secret.
|
// If TOTP is disabled, update the file to clear the TOTP secret.
|
||||||
if (!$totp_enabled) {
|
if (!$totp_enabled) {
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Leave lines with fewer than three parts unchanged.
|
// Leave lines with fewer than three parts unchanged.
|
||||||
@@ -325,7 +333,7 @@ class userModel {
|
|||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($parts[0] === $username) {
|
if ($parts[0] === $username) {
|
||||||
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
|
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
|
||||||
if (count($parts) >= 4) {
|
if (count($parts) >= 4) {
|
||||||
@@ -338,25 +346,26 @@ class userModel {
|
|||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return ["error" => "Failed to disable TOTP secret"];
|
return ["error" => "Failed to disable TOTP secret"];
|
||||||
}
|
}
|
||||||
return ["success" => "User panel updated: TOTP disabled"];
|
return ["success" => "User panel updated: TOTP disabled"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TOTP is enabled, do nothing.
|
// If TOTP is enabled, do nothing.
|
||||||
return ["success" => "User panel updated: TOTP remains enabled"];
|
return ["success" => "User panel updated: TOTP remains enabled"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disables the TOTP secret for the specified user.
|
* Disables the TOTP secret for the specified user.
|
||||||
*
|
*
|
||||||
* @param string $username The user for whom TOTP should be disabled.
|
* @param string $username The user for whom TOTP should be disabled.
|
||||||
* @return bool True if the secret was cleared; false otherwise.
|
* @return bool True if the secret was cleared; false otherwise.
|
||||||
*/
|
*/
|
||||||
public static function disableTOTPSecret($username) {
|
public static function disableTOTPSecret($username)
|
||||||
|
{
|
||||||
global $encryptionKey; // In case it's used in this model context.
|
global $encryptionKey; // In case it's used in this model context.
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
@@ -391,14 +400,15 @@ class userModel {
|
|||||||
return $modified;
|
return $modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to recover TOTP for a user using the supplied recovery code.
|
* Attempts to recover TOTP for a user using the supplied recovery code.
|
||||||
*
|
*
|
||||||
* @param string $userId The user identifier.
|
* @param string $userId The user identifier.
|
||||||
* @param string $recoveryCode The recovery code provided by the user.
|
* @param string $recoveryCode The recovery code provided by the user.
|
||||||
* @return array An associative array with keys 'status' and 'message'.
|
* @return array An associative array with keys 'status' and 'message'.
|
||||||
*/
|
*/
|
||||||
public static function recoverTOTP($userId, $recoveryCode) {
|
public static function recoverTOTP($userId, $recoveryCode)
|
||||||
|
{
|
||||||
// --- Rate‑limit recovery attempts ---
|
// --- Rate‑limit recovery attempts ---
|
||||||
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
||||||
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
||||||
@@ -406,36 +416,36 @@ class userModel {
|
|||||||
$now = time();
|
$now = time();
|
||||||
if (isset($attempts[$key])) {
|
if (isset($attempts[$key])) {
|
||||||
// Prune attempts older than 15 minutes.
|
// Prune attempts older than 15 minutes.
|
||||||
$attempts[$key] = array_filter($attempts[$key], function($ts) use ($now) {
|
$attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) {
|
||||||
return $ts > $now - 900;
|
return $ts > $now - 900;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (count($attempts[$key] ?? []) >= 5) {
|
if (count($attempts[$key] ?? []) >= 5) {
|
||||||
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Load user metadata file ---
|
// --- Load user metadata file ---
|
||||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||||
if (!file_exists($userFile)) {
|
if (!file_exists($userFile)) {
|
||||||
return ['status' => 'error', 'message' => 'User not found'];
|
return ['status' => 'error', 'message' => 'User not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Open and lock file ---
|
// --- Open and lock file ---
|
||||||
$fp = fopen($userFile, 'c+');
|
$fp = fopen($userFile, 'c+');
|
||||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||||
return ['status' => 'error', 'message' => 'Server error'];
|
return ['status' => 'error', 'message' => 'Server error'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileContents = stream_get_contents($fp);
|
$fileContents = stream_get_contents($fp);
|
||||||
$data = json_decode($fileContents, true) ?: [];
|
$data = json_decode($fileContents, true) ?: [];
|
||||||
|
|
||||||
// --- Check recovery code ---
|
// --- Check recovery code ---
|
||||||
if (empty($recoveryCode)) {
|
if (empty($recoveryCode)) {
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Recovery code required'];
|
return ['status' => 'error', 'message' => 'Recovery code required'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$storedHash = $data['totp_recovery_code'] ?? null;
|
$storedHash = $data['totp_recovery_code'] ?? null;
|
||||||
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
||||||
// Record failed attempt.
|
// Record failed attempt.
|
||||||
@@ -445,7 +455,7 @@ class userModel {
|
|||||||
fclose($fp);
|
fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Invalidate recovery code ---
|
// --- Invalidate recovery code ---
|
||||||
$data['totp_recovery_code'] = null;
|
$data['totp_recovery_code'] = null;
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
@@ -454,17 +464,18 @@ class userModel {
|
|||||||
fflush($fp);
|
fflush($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
|
|
||||||
return ['status' => 'ok'];
|
return ['status' => 'ok'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random recovery code.
|
* Generates a random recovery code.
|
||||||
*
|
*
|
||||||
* @param int $length Length of the recovery code.
|
* @param int $length Length of the recovery code.
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function generateRecoveryCode($length = 12) {
|
private static function generateRecoveryCode($length = 12)
|
||||||
|
{
|
||||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
$max = strlen($chars) - 1;
|
$max = strlen($chars) - 1;
|
||||||
$code = '';
|
$code = '';
|
||||||
@@ -480,10 +491,11 @@ class userModel {
|
|||||||
* @param string $userId The username of the user.
|
* @param string $userId The username of the user.
|
||||||
* @return array An associative array with the status and recovery code (if successful).
|
* @return array An associative array with the status and recovery code (if successful).
|
||||||
*/
|
*/
|
||||||
public static function saveTOTPRecoveryCode($userId) {
|
public static function saveTOTPRecoveryCode($userId)
|
||||||
|
{
|
||||||
// Determine the user file path.
|
// Determine the user file path.
|
||||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||||
|
|
||||||
// Ensure the file exists; if not, create it with default data.
|
// Ensure the file exists; if not, create it with default data.
|
||||||
if (!file_exists($userFile)) {
|
if (!file_exists($userFile)) {
|
||||||
$defaultData = [];
|
$defaultData = [];
|
||||||
@@ -491,24 +503,24 @@ class userModel {
|
|||||||
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new recovery code.
|
// Generate a new recovery code.
|
||||||
$recoveryCode = self::generateRecoveryCode();
|
$recoveryCode = self::generateRecoveryCode();
|
||||||
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
// Open the file, lock it, and update the totp_recovery_code field.
|
// Open the file, lock it, and update the totp_recovery_code field.
|
||||||
$fp = fopen($userFile, 'c+');
|
$fp = fopen($userFile, 'c+');
|
||||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||||
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and decode the existing JSON.
|
// Read and decode the existing JSON.
|
||||||
$contents = stream_get_contents($fp);
|
$contents = stream_get_contents($fp);
|
||||||
$data = json_decode($contents, true) ?: [];
|
$data = json_decode($contents, true) ?: [];
|
||||||
|
|
||||||
// Update the totp_recovery_code field.
|
// Update the totp_recovery_code field.
|
||||||
$data['totp_recovery_code'] = $recoveryHash;
|
$data['totp_recovery_code'] = $recoveryHash;
|
||||||
|
|
||||||
// Write the new data.
|
// Write the new data.
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
ftruncate($fp, 0);
|
ftruncate($fp, 0);
|
||||||
@@ -516,25 +528,26 @@ class userModel {
|
|||||||
fflush($fp);
|
fflush($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
|
|
||||||
return ['status' => 'ok', 'recoveryCode' => $recoveryCode];
|
return ['status' => 'ok', 'recoveryCode' => $recoveryCode];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
|
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
|
||||||
* then builds and returns a QR code image for the OTPAuth URL.
|
* then builds and returns a QR code image for the OTPAuth URL.
|
||||||
*
|
*
|
||||||
* @param string $username The username for which to set up TOTP.
|
* @param string $username The username for which to set up TOTP.
|
||||||
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
|
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
|
||||||
*/
|
*/
|
||||||
public static function setupTOTP($username) {
|
public static function setupTOTP($username)
|
||||||
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ['error' => 'Users file not found'];
|
return ['error' => 'Users file not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for an existing TOTP secret.
|
// Look for an existing TOTP secret.
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$totpSecret = null;
|
$totpSecret = null;
|
||||||
@@ -545,7 +558,7 @@ class userModel {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the TwoFactorAuth library to create a new secret if none found.
|
// Use the TwoFactorAuth library to create a new secret if none found.
|
||||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
|
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
|
||||||
@@ -557,7 +570,7 @@ class userModel {
|
|||||||
if (!$totpSecret) {
|
if (!$totpSecret) {
|
||||||
$totpSecret = $tfa->createSecret();
|
$totpSecret = $tfa->createSecret();
|
||||||
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
||||||
|
|
||||||
// Update the user’s line with the new encrypted secret.
|
// Update the user’s line with the new encrypted secret.
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
@@ -575,7 +588,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the OTPAuth URL.
|
// Determine the OTPAuth URL.
|
||||||
// Try to load a global OTPAuth URL template from admin configuration.
|
// Try to load a global OTPAuth URL template from admin configuration.
|
||||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||||
@@ -590,7 +603,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($globalOtpauthUrl)) {
|
if (!empty($globalOtpauthUrl)) {
|
||||||
$label = "FileRise:" . $username;
|
$label = "FileRise:" . $username;
|
||||||
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
||||||
@@ -599,26 +612,27 @@ class userModel {
|
|||||||
$issuer = urlencode("FileRise");
|
$issuer = urlencode("FileRise");
|
||||||
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the QR code image using the Endroid QR Code Builder.
|
// Build the QR code image using the Endroid QR Code Builder.
|
||||||
$result = \Endroid\QrCode\Builder\Builder::create()
|
$result = \Endroid\QrCode\Builder\Builder::create()
|
||||||
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
||||||
->data($otpauthUrl)
|
->data($otpauthUrl)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'imageData' => $result->getString(),
|
'imageData' => $result->getString(),
|
||||||
'mimeType' => $result->getMimeType()
|
'mimeType' => $result->getMimeType()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the decrypted TOTP secret for a given user.
|
* Retrieves the decrypted TOTP secret for a given user.
|
||||||
*
|
*
|
||||||
* @param string $username
|
* @param string $username
|
||||||
* @return string|null Returns the TOTP secret if found, or null if not.
|
* @return string|null Returns the TOTP secret if found, or null if not.
|
||||||
*/
|
*/
|
||||||
public static function getTOTPSecret($username) {
|
public static function getTOTPSecret($username)
|
||||||
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
@@ -634,14 +648,15 @@ class userModel {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get a user's role from users.txt.
|
* Helper to get a user's role from users.txt.
|
||||||
*
|
*
|
||||||
* @param string $username
|
* @param string $username
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
public static function getUserRole($username) {
|
public static function getUserRole($username)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -654,4 +669,86 @@ class userModel {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public static function getUser(string $username): array
|
||||||
|
{
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
if (! file_exists($usersFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
// split *all* the fields
|
||||||
|
$parts = explode(':', $line);
|
||||||
|
|
||||||
|
if ($parts[0] !== $username) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine admin & totp
|
||||||
|
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
|
||||||
|
$totpEnabled = !empty($parts[3]);
|
||||||
|
// profile_picture is the 5th field if present
|
||||||
|
$pic = isset($parts[4]) ? $parts[4] : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'username' => $parts[0],
|
||||||
|
'isAdmin' => $isAdmin,
|
||||||
|
'totp_enabled' => $totpEnabled,
|
||||||
|
'profile_picture' => $pic,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return []; // user not found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistently set the profile picture URL for a given user,
|
||||||
|
* storing it in the 5th field so we leave the 4th (TOTP secret) untouched.
|
||||||
|
*
|
||||||
|
* users.txt format:
|
||||||
|
* username:hash:isAdmin:totp_secret:profile_picture
|
||||||
|
*
|
||||||
|
* @param string $username
|
||||||
|
* @param string $url The public URL (e.g. "/uploads/profile_pics/…")
|
||||||
|
* @return array ['success'=>true] or ['success'=>false,'error'=>'…']
|
||||||
|
*/
|
||||||
|
public static function setProfilePicture(string $username, string $url): array
|
||||||
|
{
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
if (! file_exists($usersFile)) {
|
||||||
|
return ['success' => false, 'error' => 'Users file not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES);
|
||||||
|
$out = [];
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$parts = explode(':', $line);
|
||||||
|
if ($parts[0] === $username) {
|
||||||
|
$found = true;
|
||||||
|
// Ensure we have at least 5 fields
|
||||||
|
while (count($parts) < 5) {
|
||||||
|
$parts[] = '';
|
||||||
|
}
|
||||||
|
// Write profile_picture into the 5th field (index 4)
|
||||||
|
$parts[4] = ltrim($url, '/'); // or $url if leading slash is desired
|
||||||
|
// Re-assemble (this preserves parts[3] completely)
|
||||||
|
$line = implode(':', $parts);
|
||||||
|
}
|
||||||
|
$out[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $found) {
|
||||||
|
return ['success' => false, 'error' => 'User not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newContent = implode(PHP_EOL, $out) . PHP_EOL;
|
||||||
|
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to write users file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
105
start.sh
105
start.sh
@@ -1,35 +1,67 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
umask 002
|
||||||
echo "🚀 Running start.sh..."
|
echo "🚀 Running start.sh..."
|
||||||
|
|
||||||
# 1) Token‐key warning
|
# ──────────────────────────────────────────────────────────────
|
||||||
if [ "${PERSISTENT_TOKENS_KEY}" = "default_please_change_this_key" ]; then
|
# 0) If NOT root, we can't remap/chown. Log a hint and skip those parts.
|
||||||
echo "⚠️ WARNING: Using default persistent tokens key—override for production."
|
# If root, remap www-data to PUID/PGID and (optionally) chown data dirs.
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "[startup] Running as non-root. Skipping PUID/PGID remap and chown."
|
||||||
|
echo "[startup] Tip: remove '--user' and set PUID/PGID env vars instead."
|
||||||
|
else
|
||||||
|
# Remap www-data to match provided PUID/PGID (e.g., Unraid 99:100 or 1000:1000)
|
||||||
|
if [ -n "${PGID:-}" ]; then
|
||||||
|
current_gid="$(getent group www-data | cut -d: -f3 || true)"
|
||||||
|
if [ "${current_gid}" != "${PGID}" ]; then
|
||||||
|
groupmod -o -g "${PGID}" www-data || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${PUID:-}" ]; then
|
||||||
|
current_uid="$(id -u www-data 2>/dev/null || echo '')"
|
||||||
|
target_gid="${PGID:-$(getent group www-data | cut -d: -f3)}"
|
||||||
|
if [ "${current_uid}" != "${PUID}" ]; then
|
||||||
|
usermod -o -u "${PUID}" -g "${target_gid}" www-data || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional: normalize ownership on data dirs (good for first run on existing shares)
|
||||||
|
if [ "${CHOWN_ON_START:-true}" = "true" ]; then
|
||||||
|
echo "[startup] Normalizing ownership on uploads/metadata..."
|
||||||
|
chown -R www-data:www-data /var/www/metadata /var/www/uploads || echo "[startup] chown failed (continuing)"
|
||||||
|
chmod -R u+rwX /var/www/metadata /var/www/uploads || echo "[startup] chmod failed (continuing)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 1) Token‐key warning (guarded for -u)
|
||||||
|
if [ "${PERSISTENT_TOKENS_KEY:-}" = "default_please_change_this_key" ] || [ -z "${PERSISTENT_TOKENS_KEY:-}" ]; then
|
||||||
|
echo "⚠️ WARNING: Using default/empty persistent tokens key—override for production."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2) Update config.php based on environment variables
|
# 2) Update config.php based on environment variables
|
||||||
CONFIG_FILE="/var/www/config/config.php"
|
CONFIG_FILE="/var/www/config/config.php"
|
||||||
if [ -f "${CONFIG_FILE}" ]; then
|
if [ -f "${CONFIG_FILE}" ]; then
|
||||||
echo "🔄 Updating config.php from env vars..."
|
echo "🔄 Updating config.php from env vars..."
|
||||||
[ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
|
[ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
|
||||||
[ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
|
[ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
|
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
|
||||||
fi
|
fi
|
||||||
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
|
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
|
||||||
[ -n "${SHARE_URL:-}" ] && sed -i "s|define('SHARE_URL',[[:space:]]*'[^']*');|define('SHARE_URL', '${SHARE_URL}');|" "${CONFIG_FILE}"
|
# NOTE: SHARE_URL is read from getenv in PHP; no sed needed.
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2.1) Prepare metadata/log for Apache logs
|
# 2.1) Prepare metadata/log & sessions
|
||||||
mkdir -p /var/www/metadata/log
|
mkdir -p /var/www/metadata/log
|
||||||
chown www-data:www-data /var/www/metadata/log
|
chown www-data:www-data /var/www/metadata/log
|
||||||
chmod 775 /var/www/metadata/log
|
chmod 775 /var/www/metadata/log
|
||||||
|
|
||||||
mkdir -p /var/www/sessions
|
mkdir -p /var/www/sessions
|
||||||
chown www-data:www-data /var/www/sessions
|
chown www-data:www-data /var/www/sessions
|
||||||
chmod 700 /var/www/sessions
|
chmod 700 /var/www/sessions
|
||||||
|
|
||||||
# 2.2) Prepare other dynamic dirs
|
# 2.2) Prepare dynamic dirs (uploads/users/metadata)
|
||||||
for d in uploads users metadata; do
|
for d in uploads users metadata; do
|
||||||
tgt="/var/www/${d}"
|
tgt="/var/www/${d}"
|
||||||
mkdir -p "${tgt}"
|
mkdir -p "${tgt}"
|
||||||
@@ -37,7 +69,7 @@ for d in uploads users metadata; do
|
|||||||
chmod 775 "${tgt}"
|
chmod 775 "${tgt}"
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3) Ensure PHP config dir & set upload limits
|
# 3) Ensure PHP conf dir & set upload limits
|
||||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
|
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
|
||||||
@@ -49,8 +81,7 @@ fi
|
|||||||
|
|
||||||
# 4) Adjust Apache LimitRequestBody
|
# 4) Adjust Apache LimitRequestBody
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
# convert to bytes
|
size_str="$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')"
|
||||||
size_str=$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "${size_str: -1}" in
|
case "${size_str: -1}" in
|
||||||
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
|
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
|
||||||
m) factor=$((1024*1024)); num=${size_str%m} ;;
|
m) factor=$((1024*1024)); num=${size_str%m} ;;
|
||||||
@@ -73,29 +104,22 @@ EOF
|
|||||||
|
|
||||||
# 6) Override ports if provided
|
# 6) Override ports if provided
|
||||||
if [ -n "${HTTP_PORT:-}" ]; then
|
if [ -n "${HTTP_PORT:-}" ]; then
|
||||||
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
|
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf || true
|
||||||
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf
|
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf || true
|
||||||
fi
|
fi
|
||||||
if [ -n "${HTTPS_PORT:-}" ]; then
|
if [ -n "${HTTPS_PORT:-}" ]; then
|
||||||
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf
|
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 7) Set ServerName
|
# 7) Set ServerName (idempotent)
|
||||||
if [ -n "${SERVER_NAME:-}" ]; then
|
SN="${SERVER_NAME:-FileRise}"
|
||||||
echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
|
if grep -qE '^ServerName\s' /etc/apache2/apache2.conf; then
|
||||||
|
sed -i "s|^ServerName .*|ServerName ${SN}|" /etc/apache2/apache2.conf
|
||||||
else
|
else
|
||||||
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
|
echo "ServerName ${SN}" >> /etc/apache2/apache2.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 8) Prepare dynamic data directories with least privilege
|
# 8) Initialize persistent files if absent
|
||||||
for d in uploads users metadata; do
|
|
||||||
tgt="/var/www/${d}"
|
|
||||||
mkdir -p "${tgt}"
|
|
||||||
chown www-data:www-data "${tgt}"
|
|
||||||
chmod 775 "${tgt}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 9) Initialize persistent files if absent
|
|
||||||
if [ ! -f /var/www/users/users.txt ]; then
|
if [ ! -f /var/www/users/users.txt ]; then
|
||||||
echo "" > /var/www/users/users.txt
|
echo "" > /var/www/users/users.txt
|
||||||
chown www-data:www-data /var/www/users/users.txt
|
chown www-data:www-data /var/www/users/users.txt
|
||||||
@@ -108,5 +132,26 @@ if [ ! -f /var/www/metadata/createdTags.json ]; then
|
|||||||
chmod 664 /var/www/metadata/createdTags.json
|
chmod 664 /var/www/metadata/createdTags.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 8.5) Harden scan script perms (only if root)
|
||||||
|
if [ -f /var/www/scripts/scan_uploads.php ] && [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown root:root /var/www/scripts/scan_uploads.php
|
||||||
|
chmod 0644 /var/www/scripts/scan_uploads.php
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 9) One-shot scan when the container starts (opt-in via SCAN_ON_START)
|
||||||
|
if [ "${SCAN_ON_START:-}" = "true" ]; then
|
||||||
|
echo "[startup] Scanning uploads directory to build metadata..."
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u www-data -- /usr/bin/php /var/www/scripts/scan_uploads.php || echo "[startup] Scan failed (continuing)"
|
||||||
|
else
|
||||||
|
su -s /bin/sh -c "/usr/bin/php /var/www/scripts/scan_uploads.php" www-data || echo "[startup] Scan failed (continuing)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Non-root fallback: run as current user (permissions may limit writes)
|
||||||
|
/usr/bin/php /var/www/scripts/scan_uploads.php || echo "[startup] Scan failed (continuing)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🔥 Starting Apache..."
|
echo "🔥 Starting Apache..."
|
||||||
exec apachectl -D FOREGROUND
|
exec apachectl -D FOREGROUND
|
||||||
|
|||||||
Reference in New Issue
Block a user