Compare commits

..

24 Commits

Author SHA1 Message Date
Ryan
3dd5a8664a feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48) 2025-10-06 00:10:31 -04:00
Ryan
0cb47b4054 fix(admin): OIDC optional by default; validate only when enabled (fixes #44) 2025-10-05 05:48:25 -04:00
Ryan
e3e3aaa475 chore(scanner): skip profile_pics subtree during scans 2025-10-04 03:35:39 -04:00
Ryan
494be05801 fix(scanner): rebuild per-folder metadata to match File/Folder models 2025-10-04 03:15:55 -04:00
Ryan
ceb651894e fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash 2025-10-04 03:00:15 -04:00
Ryan
ad72ef74d1 Fix: robust PUID/PGID handling; optional ownership normalization (closes #43) 2025-10-04 02:25:16 -04:00
Ryan
680c82638f Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect 2025-10-04 01:55:02 -04:00
Ryan
31f54afc74 Fix: index externally added files on startup; harden start.sh (fixes #46) 2025-10-04 01:13:45 -04:00
Ryan
4f39b3a41e Support clipboard paste image uploads with UI cleanup (closes #40) 2025-05-27 23:48:33 -04:00
Ryan
40cecc10ad support CIFS-mounted uploads and automatic scan on container start (closes #34) 2025-05-27 19:53:00 -04:00
Ryan
aee78c9750 REGEX_FOLDER_NAME updated (closes #39) 2025-05-26 18:14:08 -04:00
Ryan
16ccb66d55 Add folder-strip context menu & combined Create File/Folder dropdown 2025-05-23 08:55:09 -04:00
Ryan
9209f7a582 Center folder strip name, fix file share url, keep fileList wrapping tight (closes #38) 2025-05-22 07:32:38 -04:00
Ryan
4a736b0224 Enable drag-and-drop to folder strip & fix restore toast messaging 2025-05-21 00:54:20 -04:00
Ryan
f162a7d0d7 updateFileActionButtons to hide or show depending on action 2025-05-20 09:55:40 -04:00
Ryan
3fc526df7f Add folder strip and “Create File” functionality (closes #36) 2025-05-19 00:39:10 -04:00
Ryan
20422cf5a7 Drag‐and‐Drop Upload extended to File List 2025-05-15 02:24:26 -04:00
Ryan
492bab36ca Fix duplicated Upload & Folder cards if they were added to header and page was refreshed 2025-05-14 08:08:18 -04:00
Ryan
f2f7697994 Link updated in readme 2025-05-14 07:09:55 -04:00
Ryan
13aa011632 #nosec to silence false positive 2025-05-14 07:05:35 -04:00
Ryan
1add160f5d setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts 2025-05-14 07:00:04 -04:00
Ryan
87368143b5 Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked 2025-05-14 06:51:16 -04:00
Ryan
939aa032f0 ui: polish header and user panel with dropdown + profile pic support & file list adjustments 2025-05-14 05:20:22 -04:00
Ryan
fbd21a035b Ensure /var/www/config exists and is owned by www-data so that start.sh sed -i updates work reliably 2025-05-08 17:20:36 -04:00
33 changed files with 3094 additions and 1014 deletions

View File

@@ -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 isnt 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 cant 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 strips `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 doesnt already exist.
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
- `fileActions.js`:
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
- Added translations for `create_new_file` and `newfile_placeholder`.
---
## Changees 5/15/2025
### DragandDrop Upload extended to File List
- **Forward filelist drops**
Dropping files onto the filelist area (`#fileListContainer`) now redispatches the same `drop` event to the upload cards drop zone (`#uploadDropArea`)
- **Visual feedback**
Added a `.drop-hover` class on `#fileListContainer` during dragover for a dashedborder + lightbackground hover state to indicate it accepts file drops.
---
## Changes 5/14/2025 v1.3.4
### 1. Button Grouping (Bootstrap)
- Converted individual action buttons (`download`, `edit`, `rename`, `share`) in both **table view** and **gallery view** into a single Bootstrap button group for a cleaner, more compact UI.
- Applied `btn-group` and `btn-sm` classes for consistent sizing and spacing.
### 2. Header Dropdown Replacement
- Replaced the standalone “User Panel” icon button with a **dropdown wrapper** (`.user-dropdown`) in the header.
- Dropdown toggle now shows:
- **Profile picture** (if set) or the Material “account_circle” icon
- **Username** text (between avatar and caret)
- Down-arrow caret span.
### 3. Menu Items Moved to Dropdown
- Moved previously standalone header buttons into the dropdown menu:
- **User Panel** opens the modal
- **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net`
- **API Docs** calls `openApiModal()`
- **Logout** calls `triggerLogout()`
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
### 4. Profile Picture Support
- Added a new `/api/profile/uploadPicture.php` endpoint + `UserController::uploadPicture()` + corresponding `UserModel::setProfilePicture()`.
- On **Open User Panel**, display:
- Default avatar if none set
- Current profile picture if available
- In the **User Panel** modal:
- Stylish “edit” overlay icon on the avatar to launch file picker
- Auto-upload on file selection (no “Save” button click needed)
- Preview updates immediately and header avatar refreshes live
- Persisted in `users.txt` and re-fetched via `getCurrentUser.php`
### 5. API Docs & Logout Relocation
- Removed API Docs from User Panel
- Removed “Logout” buttons from the header toolbar.
- Both are now menu entries in the **User Dropdown**.
### 6. Admin Panel Conditional
- The **Admin Panel** button was:
- Kept in the dropdown only when `data.isAdmin`
- Removed entirely elsewhere.
### 7. Utility & Styling Tweaks
- Introduced a small `normalizePicUrl()` helper to strip stray colons and ensure a leading slash.
- Hidden the scrollbar in the User Panel modal via:
- Inline CSS (`scrollbar-width: none; -ms-overflow-style: none;`)
- Global/WebKit rule for `::-webkit-scrollbar { display: none; }`
- Made the User Panel modal fully responsive and vertically centered, with smooth dark-mode support.
### 8. File/List View & Gallery View Sliders
- **Unified “ViewMode” Slider**
Added a single slider panel (`#viewSliderContainer`) in the filelist actions toolbar that switches behavior based on the current view mode:
- **Table View**: shows a **Row Height** slider (min 31px, max 60px).
- Adjusts the CSS variable `--file-row-height` to resize all `<tr>` heights.
- Persists the chosen height in `localStorage`.
- **Gallery View**: shows a **Columns** slider (min 1, max 6).
- Updates the grids `grid-template-columns: repeat(N, 1fr)`.
- Persists the chosen column count in `localStorage`.
- **Injection Point**
The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes.
- **Live Updates**
Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full rerender.
- **Styling & Alignment**
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
**openUserPanel**
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
**updateAuthenticatedUI**
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
- **Dropdown rebuild logic** tweaked to update the toggles innerHTML with both avatar and username on every call.
**UserModel::getUser**
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
### 10. setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
### 11. Fix duplicated Upload & Folder cards if they were added to header and page was refreshed
---
## Changes 5/8/2025
### Docker 🐳
- Ensure `/var/www/config` exists and is owned by `www-data` (chmod 750) so that `start.sh`s `sed -i` updates to `config.php` work reliably
---
## Changes 5/8/2025 v1.3.3 ## Changes 5/8/2025 v1.3.3
### Enhancements ### Enhancements

View File

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

130
README.md
View File

@@ -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 servers 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 servers 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 youre 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, youll 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 servers 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 dont 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 PHPs `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 dont 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, youll 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, youll 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; youll 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.

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error'=>'Unauthorized']);
exit;
}
$user = $_SESSION['username'];
$data = UserModel::getUser($user);
echo json_encode($data);

View File

@@ -0,0 +1,17 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
// Always JSON, even on PHP notices
header('Content-Type: application/json');
try {
$userController = new UserController();
$userController->uploadPicture();
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage()
]);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -134,17 +134,27 @@ body.dark-mode header {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 9px;
border-radius: 50%;
color: #fff; color: #fff;
transition: background-color 0.2s ease, box-shadow 0.2s ease; transition: background-color 0.2s ease, box-shadow 0.2s ease;
} }
.header-buttons button:not(#userDropdownToggle) {
border-radius: 50%;
padding: 9px;
}
#userDropdownToggle {
border-radius: 4px !important;
padding: 6px 10px !important;
}
.header-buttons button:hover { .header-buttons button:hover {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #fff;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
header { header {
flex-direction: column; flex-direction: column;
@@ -838,6 +848,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);
} }

View File

@@ -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">&times;</span>
class="editor-close-btn">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3> <h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password" <input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
placeholder="Old Password" style="width:100%; margin: 5px 0;" /> placeholder="Old Password" style="width:100%; margin: 5px 0;" />
@@ -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">

View File

@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
const version = "v1.3.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 —————

View File

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

View File

@@ -1,8 +1,7 @@
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js'; import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
let lastLoginData = null; let lastLoginData = null;
export function setLastLoginData(data) { export function setLastLoginData(data) {
@@ -60,14 +59,11 @@ export function openTOTPLoginModal() {
const totpSection = document.getElementById("totpSection"); const totpSection = document.getElementById("totpSection");
const recoverySection = document.getElementById("recoverySection"); const recoverySection = document.getElementById("recoverySection");
const toggleLink = this; const toggleLink = this;
if (recoverySection.style.display === "none") { if (recoverySection.style.display === "none") {
// Switch to recovery
totpSection.style.display = "none"; totpSection.style.display = "none";
recoverySection.style.display = "block"; recoverySection.style.display = "block";
toggleLink.textContent = t("use_totp_code_instead"); toggleLink.textContent = t("use_totp_code_instead");
} else { } else {
// Switch back to TOTP
recoverySection.style.display = "none"; recoverySection.style.display = "none";
totpSection.style.display = "block"; totpSection.style.display = "block";
toggleLink.textContent = t("use_recovery_code_instead"); toggleLink.textContent = t("use_recovery_code_instead");
@@ -93,7 +89,6 @@ export function openTOTPLoginModal() {
.then(res => res.json()) .then(res => res.json())
.then(json => { .then(json => {
if (json.status === "ok") { if (json.status === "ok") {
// recovery succeeded → finalize login
window.location.href = "/index.html"; window.location.href = "/index.html";
} else { } else {
showToast(json.message || t("recovery_code_verification_failed")); showToast(json.message || t("recovery_code_verification_failed"));
@@ -107,17 +102,11 @@ export function openTOTPLoginModal() {
// TOTP submission // TOTP submission
const totpInput = document.getElementById("totpLoginInput"); const totpInput = document.getElementById("totpLoginInput");
totpInput.focus(); totpInput.focus();
totpInput.addEventListener("input", async function () { totpInput.addEventListener("input", async function () {
const code = this.value.trim(); const code = this.value.trim();
if (code.length !== 6) { if (code.length !== 6) return;
return; const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
}
const tokenRes = await fetch("/api/auth/token.php", {
credentials: "include"
});
if (!tokenRes.ok) { if (!tokenRes.ok) {
showToast(t("totp_verification_failed")); showToast(t("totp_verification_failed"));
return; return;
@@ -144,7 +133,6 @@ export function openTOTPLoginModal() {
} else { } else {
showToast(t("totp_verification_failed")); showToast(t("totp_verification_failed"));
} }
this.value = ""; this.value = "";
totpLoginModal.style.display = "flex"; totpLoginModal.style.display = "flex";
this.focus(); this.focus();
@@ -160,153 +148,279 @@ export function openTOTPLoginModal() {
} }
} }
export function openUserPanel() { /**
const username = localStorage.getItem("username") || "User"; * Fetch current user info (username, profile_picture, totp_enabled)
let userPanelModal = document.getElementById("userPanelModal"); */
const isDarkMode = document.body.classList.contains("dark-mode"); async function fetchCurrentUser() {
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; try {
const modalContentStyles = ` const res = await fetch('/api/profile/getCurrentUser.php', {
background: ${isDarkMode ? "#2c2c2c" : "#fff"}; credentials: 'include'
color: ${isDarkMode ? "#e0e0e0" : "#000"}; });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.warn('fetchCurrentUser failed:', e);
return {};
}
}
/**
* Normalize any profilepicture URL:
* - strip leading colons
* - ensure exactly one leading slash
*/
function normalizePicUrl(raw) {
if (!raw) return '';
// take only what's after the last colon
const parts = raw.split(':');
let pic = parts[parts.length - 1];
// strip any stray colons
pic = pic.replace(/^:+/, '');
// ensure leading slash
if (pic && !pic.startsWith('/')) pic = '/' + pic;
return pic;
}
export async function openUserPanel() {
// 1) load data
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
const raw = profile_picture;
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
// 2) darkmode helpers
const isDark = document.body.classList.contains('dark-mode');
const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)';
const contentStyle = `
background: ${isDark ? '#2c2c2c' : '#fff'};
color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px; padding: 20px;
max-width: 600px; max-width: 600px; width:90%;
width: 90%;
border-radius: 8px; border-radius: 8px;
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">&times;</span> editIcon.style.cssText = 'color:#fff; font-size:16px;';
<h3>${t("user_panel")} (${username})</h3> editIcon.textContent = 'edit';
label.appendChild(editIcon);
avatarInner.appendChild(label);
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'profilePicInput';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
avatarInner.appendChild(fileInput);
avatarWrapper.appendChild(avatarInner);
content.appendChild(avatarWrapper);
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;"> // title
${t("change_password")} const title = document.createElement('h3');
</button> title.style.cssText = 'text-align:center; margin-bottom:20px;';
title.textContent = `${t('user_panel')} (${username})`;
content.appendChild(title);
<fieldset style="margin-bottom: 15px;"> // change password btn
<legend>${t("totp_settings")}</legend> const pwdBtn = document.createElement('button');
<div class="form-group"> pwdBtn.id = 'openChangePasswordModalBtn';
<label for="userTOTPEnabled">${t("enable_totp")}:</label> pwdBtn.className = 'btn btn-primary';
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" /> pwdBtn.style.marginBottom = '15px';
</div> pwdBtn.textContent = t('change_password');
</fieldset> pwdBtn.addEventListener('click', () => {
document.getElementById('changePasswordModal').style.display = 'block';
<fieldset style="margin-bottom: 15px;">
<legend>${t("language")}</legend>
<div class="form-group">
<label for="languageSelector">${t("select_language")}:</label>
<select id="languageSelector">
<option value="en">${t("english")}</option>
<option value="es">${t("spanish")}</option>
<option value="fr">${t("french")}</option>
<option value="de">${t("german")}</option>
</select>
</div>
</fieldset>
<!-- New API Docs link -->
<div style="margin-bottom: 15px;">
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
${t("api_docs") || "API Docs"}
</button>
</div>
</div>
`;
document.body.appendChild(userPanelModal);
const apiModal = document.createElement("div");
apiModal.id = "apiModal";
apiModal.style.cssText = `
position: fixed; top:0; left:0; width:100vw; height:100vh;
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
align-items: center; justify-content: center;
`;
// api.php
apiModal.innerHTML = `
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
<div class="editor-close-btn" id="closeApiModal">&times;</div>
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
</div>
`;
document.body.appendChild(apiModal);
document.getElementById("openApiModalBtn").addEventListener("click", () => {
apiModal.style.display = "flex";
});
document.getElementById("closeApiModal").addEventListener("click", () => {
apiModal.style.display = "none";
}); });
content.appendChild(pwdBtn);
// Handlers… // TOTP fieldset
document.getElementById("closeUserPanel").addEventListener("click", () => { const totpFs = document.createElement('fieldset');
userPanelModal.style.display = "none"; totpFs.style.marginBottom = '15px';
}); const totpLegend = document.createElement('legend');
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => { totpLegend.textContent = t('totp_settings');
document.getElementById("changePasswordModal").style.display = "block"; totpFs.appendChild(totpLegend);
}); const totpLabel = document.createElement('label');
totpLabel.style.cursor = 'pointer';
const totpCb = document.createElement('input');
// TOTP checkbox totpCb.type = 'checkbox';
const totpCheckbox = document.getElementById("userTOTPEnabled"); totpCb.id = 'userTOTPEnabled';
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true"; totpCb.style.verticalAlign = 'middle';
totpCheckbox.addEventListener("change", function () { totpCb.checked = totp_enabled;
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false"); totpCb.addEventListener('change', async function () {
fetch("/api/updateUserPanel.php", { const resp = await fetch('/api/updateUserPanel.php', {
method: "POST", method: 'POST', credentials: 'include',
credentials: "include", headers: {
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, 'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({ totp_enabled: this.checked }) body: JSON.stringify({ totp_enabled: this.checked })
}) });
.then(r => r.json()) const js = await resp.json();
.then(result => { if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error); else if (this.checked) openTOTPModal();
else if (this.checked) openTOTPModal();
})
.catch(() => showToast(t("error_updating_totp_setting")));
}); });
totpLabel.appendChild(totpCb);
totpLabel.append(` ${t('enable_totp')}`);
totpFs.appendChild(totpLabel);
content.appendChild(totpFs);
// Language selector // language fieldset
const languageSelector = document.getElementById("languageSelector"); const langFs = document.createElement('fieldset');
languageSelector.value = savedLanguage; langFs.style.marginBottom = '15px';
languageSelector.addEventListener("change", function () { const langLegend = document.createElement('legend');
localStorage.setItem("language", this.value); langLegend.textContent = t('language');
langFs.appendChild(langLegend);
const langSel = document.createElement('select');
langSel.id = 'languageSelector';
langSel.className = 'form-select';
['en', 'es', 'fr', 'de'].forEach(code => {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
langSel.appendChild(opt);
});
langSel.value = localStorage.getItem('language') || 'en';
langSel.addEventListener('change', function () {
localStorage.setItem('language', this.value);
setLocale(this.value); setLocale(this.value);
applyTranslations(); applyTranslations();
}); });
langFs.appendChild(langSel);
content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer';
const dispCb = document.createElement('input');
dispCb.type = 'checkbox';
dispCb.id = 'showFoldersInList';
dispCb.style.verticalAlign = 'middle';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true';
dispLabel.appendChild(dispCb);
dispLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(dispLabel);
content.appendChild(dispFs);
dispCb.addEventListener('change', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
});
// wire up imageinput change
fileInput.addEventListener('change', async function () {
const f = this.files[0];
if (!f) return;
// preview immediately
// #nosec
img.src = URL.createObjectURL(f);
const blobUrl = URL.createObjectURL(f);
// use setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
img.setAttribute('src', encodeURI(blobUrl));
// upload
const fd = new FormData();
fd.append('profile_picture', f);
try {
const res = await fetch('/api/profile/uploadPicture.php', {
method: 'POST', credentials: 'include',
headers: { 'X-CSRF-Token': window.csrfToken },
body: fd
});
const text = await res.text();
const js = JSON.parse(text || '{}');
if (!res.ok) {
showToast(js.error || t('error_updating_picture'));
return;
}
const newUrl = normalizePicUrl(js.url);
img.src = newUrl;
localStorage.setItem('profilePicUrl', newUrl);
updateAuthenticatedUI(window.__lastAuthData || {});
showToast(t('profile_picture_updated'));
} catch (e) {
console.error(e);
showToast(t('error_updating_picture'));
}
});
// finalize
modal.appendChild(content);
document.body.appendChild(modal);
} else { } else {
// Update colors if already exists // reuse on reopen
userPanelModal.style.backgroundColor = overlayBackground; Object.assign(modal.style, { background: overlayBg });
const modalContent = userPanelModal.querySelector(".modal-content"); const content = modal.querySelector('.modal-content');
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff"; content.style.cssText = contentStyle;
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000"; modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc"; modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
} }
userPanelModal.style.display = "flex"; // show
modal.style.display = 'flex';
} }
function showRecoveryCodeModal(recoveryCode) { function showRecoveryCodeModal(recoveryCode) {
@@ -314,26 +428,21 @@ function showRecoveryCodeModal(recoveryCode) {
recoveryModal.id = "recoveryModal"; recoveryModal.id = "recoveryModal";
recoveryModal.style.cssText = ` recoveryModal.style.cssText = `
position: fixed; position: fixed;
top: 0; top: 0; left: 0;
left: 0; width: 100vw; height: 100vh;
width: 100vw;
height: 100vh;
background-color: rgba(0,0,0,0.3); background-color: rgba(0,0,0,0.3);
display: flex; display: flex; justify-content: center; align-items: center;
justify-content: center;
align-items: center;
z-index: 3200; z-index: 3200;
`; `;
recoveryModal.innerHTML = ` recoveryModal.innerHTML = `
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;"> <div style="background:#fff; color:#000; padding:20px; max-width:400px; width:90%; border-radius:8px; text-align:center;">
<h3>${t("your_recovery_code")}</h3> <h3>${t("your_recovery_code")}</h3>
<p>${t("please_save_recovery_code")}</p> <p>${t("please_save_recovery_code")}</p>
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code> <code style="display:block; margin:10px 0; font-size:20px;">${recoveryCode}</code>
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button> <button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
</div> </div>
`; `;
document.body.appendChild(recoveryModal); document.body.appendChild(recoveryModal);
document.getElementById("closeRecoveryModal").addEventListener("click", () => { document.getElementById("closeRecoveryModal").addEventListener("click", () => {
recoveryModal.remove(); recoveryModal.remove();
}); });
@@ -346,106 +455,54 @@ export function openTOTPModal() {
const modalContentStyles = ` const modalContentStyles = `
background: ${isDarkMode ? "#2c2c2c" : "#fff"}; background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"}; color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px; padding: 20px; max-width:400px; width:90%; border-radius:8px; position:relative;
max-width: 400px;
width: 90%;
border-radius: 8px;
position: relative;
`; `;
if (!totpModal) { if (!totpModal) {
totpModal = document.createElement("div"); totpModal = document.createElement("div");
totpModal.id = "totpModal"; totpModal.id = "totpModal";
totpModal.style.cssText = ` totpModal.style.cssText = `
position: fixed; position: fixed; top:0; left:0; width:100vw; height:100vh;
top: 0; background-color:${overlayBackground}; display:flex; justify-content:center; align-items:center;
left: 0; z-index:3100;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3100;
`; `;
totpModal.innerHTML = ` totpModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content" style="${modalContentStyles}">
<span id="closeTOTPModal" class="editor-close-btn">&times;</span> <span id="closeTOTPModal" class="editor-close-btn">&times;</span>
<h3>${t("totp_setup")}</h3> <h3>${t("totp_setup")}</h3>
<p>${t("scan_qr_code")}</p> <p>${t("scan_qr_code")}</p>
<!-- Create an image placeholder without the CSRF token in the src --> <img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width:100%; height:auto; display:block; margin:0 auto;" />
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;"> <br/>
<br/> <p>${t("enter_totp_confirmation")}</p>
<p>${t("enter_totp_confirmation")}</p> <input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" /> <br/><br/>
<br/><br/> <button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button> </div>
</div> `;
`;
document.body.appendChild(totpModal); document.body.appendChild(totpModal);
loadTOTPQRCode(); loadTOTPQRCode();
document.getElementById("closeTOTPModal").addEventListener("click", () => closeTOTPModal(true));
document.getElementById("closeTOTPModal").addEventListener("click", () => {
closeTOTPModal(true);
});
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () { document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
const code = document.getElementById("totpConfirmInput").value.trim(); const code = document.getElementById("totpConfirmInput").value.trim();
if (code.length !== 6) { if (code.length !== 6) { showToast(t("please_enter_valid_code")); return; }
showToast(t("please_enter_valid_code")); const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
return; if (!tokenRes.ok) { showToast(t("error_verifying_totp_code")); return; }
} window.csrfToken = (await tokenRes.json()).csrf_token;
const tokenRes = await fetch("/api/auth/token.php", {
credentials: "include"
});
if (!tokenRes.ok) {
showToast(t("error_verifying_totp_code"));
return;
}
const { csrf_token } = await tokenRes.json();
window.csrfToken = csrf_token;
const verifyRes = await fetch("/api/totp_verify.php", { const verifyRes = await fetch("/api/totp_verify.php", {
method: "POST", method: "POST", credentials: "include",
credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_code: code }) body: JSON.stringify({ totp_code: code })
}); });
if (!verifyRes.ok) { showToast(t("totp_verification_failed")); return; }
if (!verifyRes.ok) {
showToast(t("totp_verification_failed"));
return;
}
const result = await verifyRes.json(); const result = await verifyRes.json();
if (result.status !== "ok") { if (result.status !== "ok") { showToast(result.message || t("totp_verification_failed")); return; }
showToast(result.message || t("totp_verification_failed"));
return;
}
showToast(t("totp_enabled_successfully")); showToast(t("totp_enabled_successfully"));
const saveRes = await fetch("/api/totp_saveCode.php", { const saveRes = await fetch("/api/totp_saveCode.php", {
method: "POST", method: "POST", credentials: "include", headers: { "X-CSRF-Token": window.csrfToken }
credentials: "include",
headers: {
"X-CSRF-Token": window.csrfToken
}
}); });
if (!saveRes.ok) { if (!saveRes.ok) { showToast(t("error_generating_recovery_code")); closeTOTPModal(false); return; }
showToast(t("error_generating_recovery_code"));
closeTOTPModal(false);
return;
}
const data = await saveRes.json(); const data = await saveRes.json();
if (data.status === "ok" && data.recoveryCode) { if (data.status === "ok" && data.recoveryCode) showRecoveryCodeModal(data.recoveryCode);
showRecoveryCodeModal(data.recoveryCode); else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
} else {
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
}
closeTOTPModal(false); closeTOTPModal(false);
}); });
@@ -458,29 +515,18 @@ export function openTOTPModal() {
}, 100); }, 100);
} }
attachEnterKeyListener("totpModal", "confirmTOTPBtn"); attachEnterKeyListener("totpModal", "confirmTOTPBtn");
} else { } else {
totpModal.style.display = "flex"; totpModal.style.display = "flex";
totpModal.style.backgroundColor = overlayBackground; totpModal.style.backgroundColor = overlayBackground;
const modalContent = totpModal.querySelector(".modal-content"); const modalContent = totpModal.querySelector(".modal-content");
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff"; modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000"; modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
// Clear any previous QR code src if needed and then load it:
const qrImg = document.getElementById("totpQRCodeImage");
if (qrImg) {
qrImg.src = "";
}
loadTOTPQRCode(); loadTOTPQRCode();
const totpInput = document.getElementById("totpConfirmInput");
// Focus the input and attach enter key listener if (totpInput) {
const totpConfirmInput = document.getElementById("totpConfirmInput"); totpInput.value = "";
if (totpConfirmInput) { setTimeout(() => totpInput.focus(), 100);
totpConfirmInput.value = "";
setTimeout(() => {
const totpConfirmInput = document.getElementById("totpConfirmInput");
if (totpConfirmInput) totpConfirmInput.focus();
}, 100);
} }
attachEnterKeyListener("totpModal", "confirmTOTPBtn"); attachEnterKeyListener("totpModal", "confirmTOTPBtn");
} }
@@ -490,42 +536,31 @@ function loadTOTPQRCode() {
fetch("/api/totp_setup.php", { fetch("/api/totp_setup.php", {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { headers: { "X-CSRF-Token": window.csrfToken }
"X-CSRF-Token": window.csrfToken // Send your CSRF token here
}
}) })
.then(response => { .then(res => {
if (!response.ok) { if (!res.ok) throw new Error("Failed to fetch QR code: " + res.status);
throw new Error("Failed to fetch QR code. Status: " + response.status); return res.blob();
}
return response.blob();
}) })
.then(blob => { .then(blob => {
const imageURL = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const qrImg = document.getElementById("totpQRCodeImage"); document.getElementById("totpQRCodeImage").src = url;
if (qrImg) {
qrImg.src = imageURL;
}
}) })
.catch(error => { .catch(err => {
console.error("Error loading TOTP QR code:", error); console.error(err);
showToast(t("error_loading_qr_code")); showToast(t("error_loading_qr_code"));
}); });
} }
// Updated closeTOTPModal function with a disable parameter
export function closeTOTPModal(disable = true) { export function closeTOTPModal(disable = true) {
const totpModal = document.getElementById("totpModal"); const totpModal = document.getElementById("totpModal");
if (totpModal) totpModal.style.display = "none"; if (totpModal) totpModal.style.display = "none";
if (disable) { if (disable) {
// Uncheck the Enable TOTP checkbox
const totpCheckbox = document.getElementById("userTOTPEnabled"); const totpCheckbox = document.getElementById("userTOTPEnabled");
if (totpCheckbox) { if (totpCheckbox) {
totpCheckbox.checked = false; totpCheckbox.checked = false;
localStorage.setItem("userTOTPEnabled", "false"); localStorage.setItem("userTOTPEnabled", "false");
} }
// Call endpoint to remove the TOTP secret from the user's record
fetch("/api/totp_disable.php", { fetch("/api/totp_disable.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@@ -536,10 +571,36 @@ export function closeTOTPModal(disable = true) {
}) })
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
if (!result.success) { if (!result.success) showToast(t("error_disabling_totp_setting") + ": " + result.error);
showToast(t("error_disabling_totp_setting") + ": " + result.error);
}
}) })
.catch(() => { showToast(t("error_disabling_totp_setting")); }); .catch(() => showToast(t("error_disabling_totp_setting")));
} }
}
export function openApiModal() {
let apiModal = document.getElementById("apiModal");
if (!apiModal) {
// create the container exactly as you do now inside openUserPanel
apiModal = document.createElement("div");
apiModal.id = "apiModal";
apiModal.style.cssText = `
position: fixed; top:0; left:0; width:100vw; height:100vh;
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
align-items: center; justify-content: center;
`;
apiModal.innerHTML = `
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
<div class="editor-close-btn" id="closeApiModal">&times;</div>
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
</div>
`;
document.body.appendChild(apiModal);
// wire up its close button
document.getElementById("closeApiModal").addEventListener("click", () => {
apiModal.style.display = "none";
});
}
// finally, show it
apiModal.style.display = "flex";
} }

View File

@@ -33,54 +33,66 @@ export function toggleAllCheckboxes(masterCheckbox) {
export function updateFileActionButtons() { export function updateFileActionButtons() {
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox"); const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked"); const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const copyBtn = document.getElementById("copySelectedBtn"); const copyBtn = document.getElementById("copySelectedBtn");
const moveBtn = document.getElementById("moveSelectedBtn"); const moveBtn = document.getElementById("moveSelectedBtn");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const zipBtn = document.getElementById("downloadZipBtn"); const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn"); const extractZipBtn = document.getElementById("extractZipBtn");
const createBtn = document.getElementById("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 shouldnt be clickable
if (deleteBtn) deleteBtn.disabled = !anySelected;
if (copyBtn) copyBtn.disabled = !anySelected;
if (moveBtn) moveBtn.disabled = !anySelected;
if (zipBtn) zipBtn.disabled = !anySelected;
if (extractZipBtn) extractZipBtn.disabled = !anyZip;
} }
export function showToast(message, duration = 3000) { export function showToast(message, duration = 3000) {
@@ -178,9 +190,14 @@ export function buildFileTableRow(file, folderPath) {
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`; previewIcon = `<i class="material-icons">audiotrack</i>`;
} }
previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}"> previewButton = `<button
${previewIcon} type="button"
</button>`; class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
} }
return ` return `
@@ -194,19 +211,44 @@ export function buildFileTableRow(file, folderPath) {
<td class="hide-small nowrap">${safeSize}</td> <td class="hide-small nowrap">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td> <td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td> <td>
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;"> <div class="btn-group btn-group-sm" role="group" aria-label="File actions">
<button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}"> <button
type="button"
class="btn btn-sm btn-success download-btn"
data-download-name="${file.name}"
data-download-folder="${file.folder || 'root'}"
title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}"> <button
<i class="material-icons">edit</i> type="button"
</button> class="btn btn-sm btn-secondary edit-btn"
` : ""} data-edit-name="${file.name}"
data-edit-folder="${file.folder || 'root'}"
title="${t('edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
${previewButton} ${previewButton}
<button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
<button
type="button"
class="btn btn-sm btn-warning rename-btn"
data-rename-name="${file.name}"
data-rename-folder="${file.folder || 'root'}"
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
<!-- share -->
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
</div> </div>
</td> </td>
</tr> </tr>

View File

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

View File

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

View File

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

View File

@@ -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; well 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 foldertile
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 doesnt exceed the new max
<span id="galleryColumnsValue">${numColumns}</span> const startCols = Math.min(numColumns, maxCols);
</div> window.galleryColumns = startCols;
`;
// --- Start gallery grid --- // --- Start gallery grid ---
galleryHTML += ` galleryHTML += `
@@ -627,32 +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);
} }

View File

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

View File

@@ -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"),

View File

@@ -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.",

View File

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

View File

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

View File

@@ -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
View 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 apps 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";

View File

@@ -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'
]; ];

View File

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

View File

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

View File

@@ -867,123 +867,126 @@ class UserController
* ) * )
*/ */
public function verifyTOTP() public function verifyTOTP()
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
// Rate-limit // Rate-limit
if (!isset($_SESSION['totp_failures'])) { if (!isset($_SESSION['totp_failures'])) {
$_SESSION['totp_failures'] = 0; $_SESSION['totp_failures'] = 0;
} }
if ($_SESSION['totp_failures'] >= 5) { if ($_SESSION['totp_failures'] >= 5) {
http_response_code(429); http_response_code(429);
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
exit; exit;
} }
// Must be authenticated OR pending login // Must be authenticated OR pending login
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) { if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
http_response_code(403); http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
exit; exit;
} }
// CSRF check // CSRF check
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = $headersArr['x-csrf-token'] ?? ''; $csrfHeader = $headersArr['x-csrf-token'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403); http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit; exit;
} }
// Parse & validate input // Parse & validate input
$inputData = json_decode(file_get_contents("php://input"), true); $inputData = json_decode(file_get_contents("php://input"), true);
$code = trim($inputData['totp_code'] ?? ''); $code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) { if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
exit; exit;
} }
// TFA helper // TFA helper
$tfa = new \RobThree\Auth\TwoFactorAuth( $tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 'FileRise',
); 6,
30,
// === Pending-login flow (we just came from auth and need to finish login) === \RobThree\Auth\Algorithm::Sha1
if (isset($_SESSION['pending_login_user'])) { );
$username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null; // === Pending-login flow (we just came from auth and need to finish login) ===
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false; if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user'];
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { $pendingSecret = $_SESSION['pending_login_secret'] ?? null;
$_SESSION['totp_failures']++; $rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
exit; $_SESSION['totp_failures']++;
} http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
// Issue “remember me” token if requested exit;
if ($rememberMe) { }
$tokFile = USERS_DIR . 'persistent_tokens.json';
$token = bin2hex(random_bytes(32)); // Issue “remember me” token if requested
$expiry = time() + 30 * 24 * 60 * 60; if ($rememberMe) {
$all = []; $tokFile = USERS_DIR . 'persistent_tokens.json';
if (file_exists($tokFile)) { $token = bin2hex(random_bytes(32));
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); $expiry = time() + 30 * 24 * 60 * 60;
$all = json_decode($dec, true) ?: []; $all = [];
} if (file_exists($tokFile)) {
$all[$token] = [ $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
'username' => $username, $all = json_decode($dec, true) ?: [];
'expiry' => $expiry, }
'isAdmin' => ((int)userModel::getUserRole($username) === 1), $all[$token] = [
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false, 'username' => $username,
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false, 'expiry' => $expiry,
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false 'isAdmin' => ((int)userModel::getUserRole($username) === 1),
]; 'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
file_put_contents( 'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
$tokFile, 'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), ];
LOCK_EX file_put_contents(
); $tokFile,
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); LOCK_EX
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); );
} $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
// === Finalize login into session exactly as finalizeLogin() would === setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
session_regenerate_id(true); }
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username; // === Finalize login into session exactly as finalizeLogin() would ===
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1); session_regenerate_id(true);
$perms = loadUserPermissions($username); $_SESSION['authenticated'] = true;
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; $_SESSION['username'] = $username;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false; $_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; $perms = loadUserPermissions($username);
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
// Clean up pending markers $_SESSION['readOnly'] = $perms['readOnly'] ?? false;
unset( $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'], // Clean up pending markers
$_SESSION['pending_login_remember_me'], unset(
$_SESSION['totp_failures'] $_SESSION['pending_login_user'],
); $_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
// Send back full login payload $_SESSION['totp_failures']
echo json_encode([ );
'status' => 'ok',
'success' => 'Login successful', // Send back full login payload
'isAdmin' => $_SESSION['isAdmin'], echo json_encode([
'folderOnly' => $_SESSION['folderOnly'], 'status' => 'ok',
'readOnly' => $_SESSION['readOnly'], 'success' => 'Login successful',
'disableUpload' => $_SESSION['disableUpload'], 'isAdmin' => $_SESSION['isAdmin'],
'username' => $_SESSION['username'] 'folderOnly' => $_SESSION['folderOnly'],
]); 'readOnly' => $_SESSION['readOnly'],
exit; 'disableUpload' => $_SESSION['disableUpload'],
} 'username' => $_SESSION['username']
]);
exit;
}
// Setup/verification flow (not pending) // Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
@@ -1011,4 +1014,91 @@ class UserController
unset($_SESSION['totp_failures']); unset($_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
} }
public function uploadPicture()
{
header('Content-Type: application/json');
// 1) Auth check
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
// 2) CSRF check
$headers = function_exists('getallheaders')
? array_change_key_case(getallheaders(), CASE_LOWER)
: [];
$csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// 3) File presence
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
exit;
}
$file = $_FILES['profile_picture'];
// 4) Validate MIME & size
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!isset($allowed[$mime])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
exit;
}
if ($file['size'] > 2 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'File too large']);
exit;
}
// 5) Destination under public/uploads/profile_pics
$uploadDir = UPLOAD_DIR . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit;
}
// 6) Move file
$ext = $allowed[$mime];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = "$uploadDir/$filename";
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit;
}
// 7) Build public URL
$url = '/uploads/profile_pics/' . $filename;
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!$result['success']) {
// on failure, remove the file we just wrote
@unlink($dest);
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Failed to save profile picture setting'
]);
exit;
}
// ─────────────────────────────────────────────────
// 8) Return success
echo json_encode(['success' => true, 'url' => $url]);
exit;
}
} }

View File

@@ -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,

View File

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

View File

@@ -3,13 +3,15 @@
require_once PROJECT_ROOT . '/config/config.php'; require_once PROJECT_ROOT . '/config/config.php';
class userModel { class userModel
{
/** /**
* Retrieves all users from the users file. * Retrieves all users from the users file.
* *
* @return array Returns an array of users. * @return array Returns an array of users.
*/ */
public static function getAllUsers() { public static function getAllUsers()
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
$users = []; $users = [];
if (file_exists($usersFile)) { if (file_exists($usersFile)) {
@@ -26,7 +28,7 @@ class userModel {
} }
return $users; return $users;
} }
/** /**
* Adds a new user. * Adds a new user.
* *
@@ -36,14 +38,15 @@ class userModel {
* @param bool $setupMode If true, overwrite the users file. * @param bool $setupMode If true, overwrite the users file.
* @return array Response containing either an error or a success message. * @return array Response containing either an error or a success message.
*/ */
public static function addUser($username, $password, $isAdmin, $setupMode) { public static function addUser($username, $password, $isAdmin, $setupMode)
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
// Ensure users.txt exists. // Ensure users.txt exists.
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
file_put_contents($usersFile, ''); file_put_contents($usersFile, '');
} }
// Check if username already exists. // Check if username already exists.
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($existingUsers as $line) { foreach ($existingUsers as $line) {
@@ -52,40 +55,41 @@ class userModel {
return ["error" => "User already exists"]; return ["error" => "User already exists"];
} }
} }
// Hash the password. // Hash the password.
$hashedPassword = password_hash($password, PASSWORD_BCRYPT); $hashedPassword = password_hash($password, PASSWORD_BCRYPT);
// Prepare the new line. // Prepare the new line.
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL; $newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
// If setup mode, overwrite the file; otherwise, append. // If setup mode, overwrite the file; otherwise, append.
if ($setupMode) { if ($setupMode) {
file_put_contents($usersFile, $newUserLine); file_put_contents($usersFile, $newUserLine);
} else { } else {
file_put_contents($usersFile, $newUserLine, FILE_APPEND); file_put_contents($usersFile, $newUserLine, FILE_APPEND);
} }
return ["success" => "User added successfully"]; return ["success" => "User added successfully"];
} }
/** /**
* Removes the specified user from the users file and updates the userPermissions file. * Removes the specified user from the users file and updates the userPermissions file.
* *
* @param string $usernameToRemove The username to remove. * @param string $usernameToRemove The username to remove.
* @return array An array with either an error message or a success message. * @return array An array with either an error message or a success message.
*/ */
public static function removeUser($usernameToRemove) { public static function removeUser($usernameToRemove)
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return ["error" => "Users file not found"]; return ["error" => "Users file not found"];
} }
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newUsers = []; $newUsers = [];
$userFound = false; $userFound = false;
// Loop through users; skip (remove) the specified user. // Loop through users; skip (remove) the specified user.
foreach ($existingUsers as $line) { foreach ($existingUsers as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
@@ -98,14 +102,14 @@ class userModel {
} }
$newUsers[] = $line; $newUsers[] = $line;
} }
if (!$userFound) { if (!$userFound) {
return ["error" => "User not found"]; return ["error" => "User not found"];
} }
// Write the updated user list back to the file. // Write the updated user list back to the file.
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL); file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
// Update the userPermissions.json file. // Update the userPermissions.json file.
$permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsFile = USERS_DIR . "userPermissions.json";
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
@@ -116,18 +120,19 @@ class userModel {
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT)); file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
} }
} }
return ["success" => "User removed successfully"]; return ["success" => "User removed successfully"];
} }
/** /**
* Retrieves permissions from the userPermissions.json file. * Retrieves permissions from the userPermissions.json file.
* If the current user is an admin, returns all permissions. * If the current user is an admin, returns all permissions.
* Otherwise, returns only the permissions for the current user. * Otherwise, returns only the permissions for the current user.
* *
* @return array|object Returns an associative array of permissions or an empty object if none are found. * @return array|object Returns an associative array of permissions or an empty object if none are found.
*/ */
public static function getUserPermissions() { public static function getUserPermissions()
{
global $encryptionKey; global $encryptionKey;
$permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsFile = USERS_DIR . "userPermissions.json";
$permissionsArray = []; $permissionsArray = [];
@@ -165,13 +170,14 @@ class userModel {
return new stdClass(); return new stdClass();
} }
/** /**
* Updates user permissions in the userPermissions.json file. * Updates user permissions in the userPermissions.json file.
* *
* @param array $permissions An array of permission updates. * @param array $permissions An array of permission updates.
* @return array An associative array with a success or error message. * @return array An associative array with a success or error message.
*/ */
public static function updateUserPermissions($permissions) { public static function updateUserPermissions($permissions)
{
global $encryptionKey; global $encryptionKey;
$permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsFile = USERS_DIR . "userPermissions.json";
$existingPermissions = []; $existingPermissions = [];
@@ -185,7 +191,7 @@ class userModel {
$existingPermissions = []; $existingPermissions = [];
} }
} }
// Load user roles from the users file. // Load user roles from the users file.
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
$userRoles = []; $userRoles = [];
@@ -199,7 +205,7 @@ class userModel {
} }
} }
} }
// Process each permission update. // Process each permission update.
foreach ($permissions as $perm) { foreach ($permissions as $perm) {
if (!isset($perm['username'])) { if (!isset($perm['username'])) {
@@ -208,12 +214,12 @@ class userModel {
$username = $perm['username']; $username = $perm['username'];
// Look up the user's role. // Look up the user's role.
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null; $role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
// Skip updating permissions for admin users. // Skip updating permissions for admin users.
if ($role === "1") { if ($role === "1") {
continue; continue;
} }
// Update permissions: default any missing value to false. // Update permissions: default any missing value to false.
$existingPermissions[strtolower($username)] = [ $existingPermissions[strtolower($username)] = [
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false, 'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
@@ -221,7 +227,7 @@ class userModel {
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false 'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
]; ];
} }
// Convert the updated permissions array to JSON. // Convert the updated permissions array to JSON.
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT); $plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
// Encrypt the JSON. // Encrypt the JSON.
@@ -231,11 +237,11 @@ class userModel {
if ($result === false) { if ($result === false) {
return ["error" => "Failed to save user permissions."]; return ["error" => "Failed to save user permissions."];
} }
return ["success" => "User permissions updated successfully."]; return ["success" => "User permissions updated successfully."];
} }
/** /**
* Changes the password for the given user. * Changes the password for the given user.
* *
* @param string $username The username whose password is to be changed. * @param string $username The username whose password is to be changed.
@@ -243,17 +249,18 @@ class userModel {
* @param string $newPassword The new password. * @param string $newPassword The new password.
* @return array An array with either a success or error message. * @return array An array with either a success or error message.
*/ */
public static function changePassword($username, $oldPassword, $newPassword) { public static function changePassword($username, $oldPassword, $newPassword)
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return ["error" => "Users file not found"]; return ["error" => "Users file not found"];
} }
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$userFound = false; $userFound = false;
$newLines = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
// Expect at least 3 parts: username, hashed password, and role. // Expect at least 3 parts: username, hashed password, and role.
@@ -266,7 +273,7 @@ class userModel {
$storedRole = $parts[2]; $storedRole = $parts[2];
// Preserve TOTP secret if it exists. // Preserve TOTP secret if it exists.
$totpSecret = (count($parts) >= 4) ? $parts[3] : ""; $totpSecret = (count($parts) >= 4) ? $parts[3] : "";
if ($storedUser === $username) { if ($storedUser === $username) {
$userFound = true; $userFound = true;
// Verify the old password. // Verify the old password.
@@ -275,7 +282,7 @@ class userModel {
} }
// Hash the new password. // Hash the new password.
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT); $newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
// Rebuild the line, preserving TOTP secret if it exists. // Rebuild the line, preserving TOTP secret if it exists.
if ($totpSecret !== "") { if ($totpSecret !== "") {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret; $newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
@@ -286,11 +293,11 @@ class userModel {
$newLines[] = $line; $newLines[] = $line;
} }
} }
if (!$userFound) { if (!$userFound) {
return ["error" => "User not found."]; return ["error" => "User not found."];
} }
// Save the updated users file. // Save the updated users file.
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) { if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
return ["success" => "Password updated successfully."]; return ["success" => "Password updated successfully."];
@@ -299,25 +306,26 @@ class userModel {
} }
} }
/** /**
* Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled. * Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled.
* *
* @param string $username The username whose panel settings are being updated. * @param string $username The username whose panel settings are being updated.
* @param bool $totp_enabled Whether TOTP is enabled. * @param bool $totp_enabled Whether TOTP is enabled.
* @return array An array indicating success or failure. * @return array An array indicating success or failure.
*/ */
public static function updateUserPanel($username, $totp_enabled) { public static function updateUserPanel($username, $totp_enabled)
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return ["error" => "Users file not found"]; return ["error" => "Users file not found"];
} }
// If TOTP is disabled, update the file to clear the TOTP secret. // If TOTP is disabled, update the file to clear the TOTP secret.
if (!$totp_enabled) { if (!$totp_enabled) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newLines = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
// Leave lines with fewer than three parts unchanged. // Leave lines with fewer than three parts unchanged.
@@ -325,7 +333,7 @@ class userModel {
$newLines[] = $line; $newLines[] = $line;
continue; continue;
} }
if ($parts[0] === $username) { if ($parts[0] === $username) {
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field. // If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
if (count($parts) >= 4) { if (count($parts) >= 4) {
@@ -338,25 +346,26 @@ class userModel {
$newLines[] = $line; $newLines[] = $line;
} }
} }
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); $result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
if ($result === false) { if ($result === false) {
return ["error" => "Failed to disable TOTP secret"]; return ["error" => "Failed to disable TOTP secret"];
} }
return ["success" => "User panel updated: TOTP disabled"]; return ["success" => "User panel updated: TOTP disabled"];
} }
// If TOTP is enabled, do nothing. // If TOTP is enabled, do nothing.
return ["success" => "User panel updated: TOTP remains enabled"]; return ["success" => "User panel updated: TOTP remains enabled"];
} }
/** /**
* Disables the TOTP secret for the specified user. * Disables the TOTP secret for the specified user.
* *
* @param string $username The user for whom TOTP should be disabled. * @param string $username The user for whom TOTP should be disabled.
* @return bool True if the secret was cleared; false otherwise. * @return bool True if the secret was cleared; false otherwise.
*/ */
public static function disableTOTPSecret($username) { public static function disableTOTPSecret($username)
{
global $encryptionKey; // In case it's used in this model context. global $encryptionKey; // In case it's used in this model context.
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
@@ -391,14 +400,15 @@ class userModel {
return $modified; return $modified;
} }
/** /**
* Attempts to recover TOTP for a user using the supplied recovery code. * Attempts to recover TOTP for a user using the supplied recovery code.
* *
* @param string $userId The user identifier. * @param string $userId The user identifier.
* @param string $recoveryCode The recovery code provided by the user. * @param string $recoveryCode The recovery code provided by the user.
* @return array An associative array with keys 'status' and 'message'. * @return array An associative array with keys 'status' and 'message'.
*/ */
public static function recoverTOTP($userId, $recoveryCode) { public static function recoverTOTP($userId, $recoveryCode)
{
// --- Ratelimit recovery attempts --- // --- Ratelimit recovery attempts ---
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json'; $attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : []; $attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
@@ -406,36 +416,36 @@ class userModel {
$now = time(); $now = time();
if (isset($attempts[$key])) { if (isset($attempts[$key])) {
// Prune attempts older than 15 minutes. // Prune attempts older than 15 minutes.
$attempts[$key] = array_filter($attempts[$key], function($ts) use ($now) { $attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) {
return $ts > $now - 900; return $ts > $now - 900;
}); });
} }
if (count($attempts[$key] ?? []) >= 5) { if (count($attempts[$key] ?? []) >= 5) {
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.']; return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
} }
// --- Load user metadata file --- // --- Load user metadata file ---
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
if (!file_exists($userFile)) { if (!file_exists($userFile)) {
return ['status' => 'error', 'message' => 'User not found']; return ['status' => 'error', 'message' => 'User not found'];
} }
// --- Open and lock file --- // --- Open and lock file ---
$fp = fopen($userFile, 'c+'); $fp = fopen($userFile, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) { if (!$fp || !flock($fp, LOCK_EX)) {
return ['status' => 'error', 'message' => 'Server error']; return ['status' => 'error', 'message' => 'Server error'];
} }
$fileContents = stream_get_contents($fp); $fileContents = stream_get_contents($fp);
$data = json_decode($fileContents, true) ?: []; $data = json_decode($fileContents, true) ?: [];
// --- Check recovery code --- // --- Check recovery code ---
if (empty($recoveryCode)) { if (empty($recoveryCode)) {
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
return ['status' => 'error', 'message' => 'Recovery code required']; return ['status' => 'error', 'message' => 'Recovery code required'];
} }
$storedHash = $data['totp_recovery_code'] ?? null; $storedHash = $data['totp_recovery_code'] ?? null;
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) { if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
// Record failed attempt. // Record failed attempt.
@@ -445,7 +455,7 @@ class userModel {
fclose($fp); fclose($fp);
return ['status' => 'error', 'message' => 'Invalid recovery code']; return ['status' => 'error', 'message' => 'Invalid recovery code'];
} }
// --- Invalidate recovery code --- // --- Invalidate recovery code ---
$data['totp_recovery_code'] = null; $data['totp_recovery_code'] = null;
rewind($fp); rewind($fp);
@@ -454,17 +464,18 @@ class userModel {
fflush($fp); fflush($fp);
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
return ['status' => 'ok']; return ['status' => 'ok'];
} }
/** /**
* Generates a random recovery code. * Generates a random recovery code.
* *
* @param int $length Length of the recovery code. * @param int $length Length of the recovery code.
* @return string * @return string
*/ */
private static function generateRecoveryCode($length = 12) { private static function generateRecoveryCode($length = 12)
{
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$max = strlen($chars) - 1; $max = strlen($chars) - 1;
$code = ''; $code = '';
@@ -480,10 +491,11 @@ class userModel {
* @param string $userId The username of the user. * @param string $userId The username of the user.
* @return array An associative array with the status and recovery code (if successful). * @return array An associative array with the status and recovery code (if successful).
*/ */
public static function saveTOTPRecoveryCode($userId) { public static function saveTOTPRecoveryCode($userId)
{
// Determine the user file path. // Determine the user file path.
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
// Ensure the file exists; if not, create it with default data. // Ensure the file exists; if not, create it with default data.
if (!file_exists($userFile)) { if (!file_exists($userFile)) {
$defaultData = []; $defaultData = [];
@@ -491,24 +503,24 @@ class userModel {
return ['status' => 'error', 'message' => 'Server error: could not create user file']; return ['status' => 'error', 'message' => 'Server error: could not create user file'];
} }
} }
// Generate a new recovery code. // Generate a new recovery code.
$recoveryCode = self::generateRecoveryCode(); $recoveryCode = self::generateRecoveryCode();
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT); $recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
// Open the file, lock it, and update the totp_recovery_code field. // Open the file, lock it, and update the totp_recovery_code field.
$fp = fopen($userFile, 'c+'); $fp = fopen($userFile, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) { if (!$fp || !flock($fp, LOCK_EX)) {
return ['status' => 'error', 'message' => 'Server error: could not lock user file']; return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
} }
// Read and decode the existing JSON. // Read and decode the existing JSON.
$contents = stream_get_contents($fp); $contents = stream_get_contents($fp);
$data = json_decode($contents, true) ?: []; $data = json_decode($contents, true) ?: [];
// Update the totp_recovery_code field. // Update the totp_recovery_code field.
$data['totp_recovery_code'] = $recoveryHash; $data['totp_recovery_code'] = $recoveryHash;
// Write the new data. // Write the new data.
rewind($fp); rewind($fp);
ftruncate($fp, 0); ftruncate($fp, 0);
@@ -516,25 +528,26 @@ class userModel {
fflush($fp); fflush($fp);
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
return ['status' => 'ok', 'recoveryCode' => $recoveryCode]; return ['status' => 'ok', 'recoveryCode' => $recoveryCode];
} }
/** /**
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret, * Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
* then builds and returns a QR code image for the OTPAuth URL. * then builds and returns a QR code image for the OTPAuth URL.
* *
* @param string $username The username for which to set up TOTP. * @param string $username The username for which to set up TOTP.
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'. * @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
*/ */
public static function setupTOTP($username) { public static function setupTOTP($username)
{
global $encryptionKey; global $encryptionKey;
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return ['error' => 'Users file not found']; return ['error' => 'Users file not found'];
} }
// Look for an existing TOTP secret. // Look for an existing TOTP secret.
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totpSecret = null; $totpSecret = null;
@@ -545,7 +558,7 @@ class userModel {
break; break;
} }
} }
// Use the TwoFactorAuth library to create a new secret if none found. // Use the TwoFactorAuth library to create a new secret if none found.
$tfa = new \RobThree\Auth\TwoFactorAuth( $tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
@@ -557,7 +570,7 @@ class userModel {
if (!$totpSecret) { if (!$totpSecret) {
$totpSecret = $tfa->createSecret(); $totpSecret = $tfa->createSecret();
$encryptedSecret = encryptData($totpSecret, $encryptionKey); $encryptedSecret = encryptData($totpSecret, $encryptionKey);
// Update the users line with the new encrypted secret. // Update the users line with the new encrypted secret.
$newLines = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
@@ -575,7 +588,7 @@ class userModel {
} }
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
} }
// Determine the OTPAuth URL. // Determine the OTPAuth URL.
// Try to load a global OTPAuth URL template from admin configuration. // Try to load a global OTPAuth URL template from admin configuration.
$adminConfigFile = USERS_DIR . 'adminConfig.json'; $adminConfigFile = USERS_DIR . 'adminConfig.json';
@@ -590,7 +603,7 @@ class userModel {
} }
} }
} }
if (!empty($globalOtpauthUrl)) { if (!empty($globalOtpauthUrl)) {
$label = "FileRise:" . $username; $label = "FileRise:" . $username;
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl); $otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
@@ -599,26 +612,27 @@ class userModel {
$issuer = urlencode("FileRise"); $issuer = urlencode("FileRise");
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}"; $otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
} }
// Build the QR code image using the Endroid QR Code Builder. // Build the QR code image using the Endroid QR Code Builder.
$result = \Endroid\QrCode\Builder\Builder::create() $result = \Endroid\QrCode\Builder\Builder::create()
->writer(new \Endroid\QrCode\Writer\PngWriter()) ->writer(new \Endroid\QrCode\Writer\PngWriter())
->data($otpauthUrl) ->data($otpauthUrl)
->build(); ->build();
return [ return [
'imageData' => $result->getString(), 'imageData' => $result->getString(),
'mimeType' => $result->getMimeType() 'mimeType' => $result->getMimeType()
]; ];
} }
/** /**
* Retrieves the decrypted TOTP secret for a given user. * Retrieves the decrypted TOTP secret for a given user.
* *
* @param string $username * @param string $username
* @return string|null Returns the TOTP secret if found, or null if not. * @return string|null Returns the TOTP secret if found, or null if not.
*/ */
public static function getTOTPSecret($username) { public static function getTOTPSecret($username)
{
global $encryptionKey; global $encryptionKey;
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
@@ -634,14 +648,15 @@ class userModel {
} }
return null; return null;
} }
/** /**
* Helper to get a user's role from users.txt. * Helper to get a user's role from users.txt.
* *
* @param string $username * @param string $username
* @return string|null * @return string|null
*/ */
public static function getUserRole($username) { public static function getUserRole($username)
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return null; return null;
@@ -654,4 +669,86 @@ class userModel {
} }
return null; return null;
} }
}
public static function getUser(string $username): array
{
$usersFile = USERS_DIR . USERS_FILE;
if (! file_exists($usersFile)) {
return [];
}
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
// split *all* the fields
$parts = explode(':', $line);
if ($parts[0] !== $username) {
continue;
}
// determine admin & totp
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
$totpEnabled = !empty($parts[3]);
// profile_picture is the 5th field if present
$pic = isset($parts[4]) ? $parts[4] : '';
return [
'username' => $parts[0],
'isAdmin' => $isAdmin,
'totp_enabled' => $totpEnabled,
'profile_picture' => $pic,
];
}
return []; // user not found
}
/**
* Persistently set the profile picture URL for a given user,
* storing it in the 5th field so we leave the 4th (TOTP secret) untouched.
*
* users.txt format:
* username:hash:isAdmin:totp_secret:profile_picture
*
* @param string $username
* @param string $url The public URL (e.g. "/uploads/profile_pics/…")
* @return array ['success'=>true] or ['success'=>false,'error'=>'…']
*/
public static function setProfilePicture(string $username, string $url): array
{
$usersFile = USERS_DIR . USERS_FILE;
if (! file_exists($usersFile)) {
return ['success' => false, 'error' => 'Users file not found'];
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES);
$out = [];
$found = false;
foreach ($lines as $line) {
$parts = explode(':', $line);
if ($parts[0] === $username) {
$found = true;
// Ensure we have at least 5 fields
while (count($parts) < 5) {
$parts[] = '';
}
// Write profile_picture into the 5th field (index 4)
$parts[4] = ltrim($url, '/'); // or $url if leading slash is desired
// Re-assemble (this preserves parts[3] completely)
$line = implode(':', $parts);
}
$out[] = $line;
}
if (! $found) {
return ['success' => false, 'error' => 'User not found'];
}
$newContent = implode(PHP_EOL, $out) . PHP_EOL;
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
return ['success' => false, 'error' => 'Failed to write users file'];
}
return ['success' => true];
}
}

105
start.sh
View File

@@ -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) Tokenkey 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) Tokenkey 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