Compare commits

...

17 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
25 changed files with 1838 additions and 469 deletions

View File

@@ -1,5 +1,202 @@
# 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)

128
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.
### 1. Running with Docker (Recommended)
---
If you have Docker installed, you can get FileRise up and running in minutes:
### 1) Running with Docker (Recommended)
- **Pull the image from Docker Hub:**
#### Pull the image
``` bash
```bash
docker pull error311/filerise-docker:latest
```
- **Run a container:**
#### Run a container
``` bash
```bash
docker run -d \
--name filerise \
-p 8080:80 \
-e TIMEZONE="America/New_York" \
-e DATE_TIME_FORMAT="m/d/y h:iA" \
-e TOTAL_UPLOAD_SIZE="5G" \
-e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
-e PUID="1000" \
-e PGID="1000" \
-e CHOWN_ON_START="true" \
-e SCAN_ON_START="true" \
-e SHARE_URL="" \
-v ~/filerise/uploads:/var/www/uploads \
-v ~/filerise/users:/var/www/users \
-v ~/filerise/metadata:/var/www/metadata \
--name filerise \
error311/filerise-docker:latest
```
```
This will start FileRise on port 8080. Visit `http://your-server-ip:8080` to access it. Environment variables shown above are optional for instance, set `SECURE="true"` to enforce HTTPS (assuming you have SSL at proxy level) and adjust `TIMEZONE` as needed. The volume mounts ensure your files and user data persist outside the container.
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
- **Using Docker Compose:**
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
**Notes**
``` yaml
version: '3'
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
- `CHOWN_ON_START=true` is recommended on **first run** to normalize ownership of existing trees. Set to **false** later for faster restarts.
- `SCAN_ON_START=true` runs a one-time index of files added outside the UI so their metadata appears.
- `SHARE_URL` is optional; leave blank to auto-detect from the current host/scheme. You can set it to your site root (e.g., `https://files.example.com`) or directly to the full endpoint.
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
**Verify ownership mapping (optional)**
```bash
docker exec -it filerise id www-data
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
```
#### Using Docker Compose
Save as `docker-compose.yml`, then `docker-compose up -d`:
```yaml
version: "3"
services:
filerise:
image: error311/filerise-docker:latest
@@ -91,59 +114,88 @@ services:
- "8080:80"
environment:
TIMEZONE: "UTC"
DATE_TIME_FORMAT: "m/d/y h:iA"
TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false"
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
# Ownership & indexing
PUID: "1000" # Unraid users often use 99
PGID: "1000" # Unraid users often use 100
CHOWN_ON_START: "true" # first run; set to "false" afterwards
SCAN_ON_START: "true" # index files added outside the UI at boot
# Sharing URL (optional): leave blank to auto-detect from host/scheme
SHARE_URL: ""
volumes:
- ./uploads:/var/www/uploads
- ./users:/var/www/users
- ./metadata:/var/www/metadata
```
FileRise will be accessible at `http://localhost:8080` (or your 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):
- **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.
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
**Requirements**
``` bash
git clone https://github.com/error311/FileRise.git
- PHP **8.3+**
- Apache (mod_php) or another web server configured for PHP
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
**Download Files**
```bash
git clone https://github.com/error311/FileRise.git
```
Place the files into your web 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
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate user
chown -R www-data:www-data uploads users metadata # use your web user
chmod -R 775 uploads users metadata
```
The uploads/ folder is where files go, users/ stores the user credentials file, and metadata/ holds metadata like tags and share links.
- `uploads/`: actual files
- `users/`: credentials & token storage
- `metadata/`: file metadata (tags, share links, etc.)
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
**Configuration**
- `BASE_URL` the URL where you will access FileRise (e.g., `“https://files.mydomain.com/”`). This is used for generating share links.
- `TIMEZONE` and `DATE_TIME_FORMAT` match your locale (for correct timestamps).
- `TOTAL_UPLOAD_SIZE` max aggregate upload size (default 5G). Also adjust 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.
Open `config.php` and consider:
- **Web Server Config:** If using Apache, ensure `.htaccess` files are allowed or manually add the rules from `.htaccess` to your Apache config these disable directory listings and prevent access to certain files. For Nginx or others, 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.
---

View File

@@ -28,7 +28,7 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
define('TIMEZONE', 'America/New_York');
define('DATE_TIME_FORMAT','m/d/y h:iA');
define('TOTAL_UPLOAD_SIZE','5G');
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
@@ -196,13 +196,21 @@ if (AUTH_BYPASS) {
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
}
}
// Share URL fallback
// Share URL fallback (keep BASE_URL behavior)
define('BASE_URL', 'http://yourwebsite/uploads/');
// Detect scheme correctly (works behind proxies too)
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
);
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShare = isset($_SERVER['HTTP_HOST'])
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
: "http://localhost/api/file/share.php";
$defaultShare = "{$proto}://{$host}/api/file/share.php";
} else {
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
}
// Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);

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

@@ -848,6 +848,27 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
background-color: #00796B;
}
#createBtn {
background-color: #007bff;
color: white;
}
body.dark-mode .dropdown-menu {
background-color: #2c2c2c !important;
border-color: #444 !important;
color: #e0e0e0!important;
}
body.dark-mode .dropdown-menu .dropdown-item {
color: #e0e0e0 !important;
}
.dropdown-item:hover {
background-color: rgba(0,0,0,0.05);
}
body.dark-mode .dropdown-item:hover {
background-color: rgba(255,255,255,0.1);
}
#fileList button.edit-btn {
background-color: #007bff;
color: white;
@@ -966,16 +987,15 @@ body.dark-mode #fileList table tr {
}
:root {
--file-row-height: 48px; /* default, will be overwritten by your slider */
--file-row-height: 48px;
}
/* Force each <tr> to be exactly the var() height */
#fileList table.table tbody tr {
height: var(--file-row-height) !important;
height: auto !important;
min-height: var(--file-row-height) !important;
}
/* And force each <td> to match, with no extra padding or line-height */
#fileList table.table tbody td {
#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;
@@ -983,6 +1003,13 @@ body.dark-mode #fileList table tr {
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
=========================================================== */
@@ -2242,4 +2269,40 @@ body.dark-mode .user-dropdown .user-menu .item:hover {
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="">
<style>
/* hide the app shell until JS says otherwise */
.main-wrapper { display: none; }
.main-wrapper {
display: none;
}
/* full-screen white overlay while we check auth */
#loadingOverlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--bg-color,#fff);
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-color, #fff);
z-index: 9999;
display: flex;
align-items: center;
@@ -384,8 +389,55 @@
</div>
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip"
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
data-i18n-key="extract_zip_button">Extract Zip</button>
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button>
<ul
id="createMenu"
class="dropdown-menu"
style="
display: none;
position: absolute;
top: 100%;
left: 0;
margin: 4px 0 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000;
min-width: 140px;
"
>
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
</li>
</ul>
</div>
<!-- Create File Modal -->
<div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="create_new_file">Create New File</h4>
<input
type="text"
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder"
/>
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
</div>
</div>
</div>
<div id="downloadZipModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
@@ -440,8 +492,7 @@
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal"
class="editor-close-btn">&times;</span>
<span id="closeChangePasswordModal" class="editor-close-btn">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
@@ -459,15 +510,15 @@
<form id="addUserForm">
<label for="newUsername" data-i18n-key="username">Username:</label>
<input type="text" id="newUsername" class="form-control" required />
<label for="addUserPassword" data-i18n-key="password">Password:</label>
<input type="password" id="addUserPassword" class="form-control" required />
<div id="adminCheckboxContainer">
<input type="checkbox" id="isAdmin" />
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
</div>
<div class="button-container">
<!-- Cancel stays type="button" -->
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">

View File

@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js';
const version = "v1.3.4";
const version = "v1.3.15";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// ————— Inject updated styles —————

View File

@@ -184,11 +184,11 @@ function normalizePicUrl(raw) {
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';
const raw = profile_picture;
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
// 2) darkmode helpers
const isDark = document.body.classList.contains('dark-mode');
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'};
@@ -196,7 +196,7 @@ export async function openUserPanel() {
padding: 20px;
max-width: 600px; width:90%;
border-radius: 8px;
overflow-y: auto; max-height: 415px;
overflow-y: auto; max-height: 500px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box;
scrollbar-width: none;
@@ -210,16 +210,16 @@ export async function openUserPanel() {
modal = document.createElement('div');
modal.id = 'userPanelModal';
Object.assign(modal.style, {
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
background: overlayBg,
display: 'flex',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '1000',
zIndex: '1000',
});
// content container
@@ -264,7 +264,7 @@ export async function openUserPanel() {
avatarInner.appendChild(label);
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'profilePicInput';
fileInput.id = 'profilePicInput';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
avatarInner.appendChild(fileInput);
@@ -301,11 +301,11 @@ export async function openUserPanel() {
totpCb.id = 'userTOTPEnabled';
totpCb.style.verticalAlign = 'middle';
totpCb.checked = totp_enabled;
totpCb.addEventListener('change', async function() {
totpCb.addEventListener('change', async function () {
const resp = await fetch('/api/updateUserPanel.php', {
method: 'POST', credentials: 'include',
headers: {
'Content-Type':'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({ totp_enabled: this.checked })
@@ -328,14 +328,14 @@ export async function openUserPanel() {
const langSel = document.createElement('select');
langSel.id = 'languageSelector';
langSel.className = 'form-select';
['en','es','fr','de'].forEach(code => {
['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');
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() {
langSel.addEventListener('change', function () {
localStorage.setItem('language', this.value);
setLocale(this.value);
applyTranslations();
@@ -343,8 +343,34 @@ export async function openUserPanel() {
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() {
fileInput.addEventListener('change', async function () {
const f = this.files[0];
if (!f) return;
// preview immediately
@@ -357,13 +383,13 @@ export async function openUserPanel() {
const fd = new FormData();
fd.append('profile_picture', f);
try {
const res = await fetch('/api/profile/uploadPicture.php', {
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 || '{}');
const js = JSON.parse(text || '{}');
if (!res.ok) {
showToast(js.error || t('error_updating_picture'));
return;
@@ -387,9 +413,9 @@ export async function openUserPanel() {
Object.assign(modal.style, { background: overlayBg });
const content = modal.querySelector('.modal-content');
content.style.cssText = contentStyle;
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
}
@@ -479,7 +505,7 @@ export function openTOTPModal() {
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
closeTOTPModal(false);
});
// Focus the input and attach enter key listener
const totpConfirmInput = document.getElementById("totpConfirmInput");
if (totpConfirmInput) {

View File

@@ -33,54 +33,66 @@ export function toggleAllCheckboxes(masterCheckbox) {
export function updateFileActionButtons() {
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const copyBtn = document.getElementById("copySelectedBtn");
const moveBtn = document.getElementById("moveSelectedBtn");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn");
const createBtn = document.getElementById("createBtn");
// keep the “select all” in sync ——
const master = document.getElementById("selectAll");
if (master) {
if (selectedCheckboxes.length === fileCheckboxes.length) {
master.checked = true;
master.indeterminate = false;
} else if (selectedCheckboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
const anyFiles = fileCheckboxes.length > 0;
const anySelected = selectedCheckboxes.length > 0;
const anyZip = Array.from(selectedCheckboxes)
.some(cb => cb.value.toLowerCase().endsWith(".zip"));
if (fileCheckboxes.length === 0) {
if (copyBtn) copyBtn.style.display = "none";
if (moveBtn) moveBtn.style.display = "none";
if (deleteBtn) deleteBtn.style.display = "none";
if (zipBtn) zipBtn.style.display = "none";
if (extractZipBtn) extractZipBtn.style.display = "none";
} else {
if (copyBtn) copyBtn.style.display = "inline-block";
if (moveBtn) moveBtn.style.display = "inline-block";
if (deleteBtn) deleteBtn.style.display = "inline-block";
if (zipBtn) zipBtn.style.display = "inline-block";
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
const anySelected = selectedCheckboxes.length > 0;
if (copyBtn) copyBtn.disabled = !anySelected;
if (moveBtn) moveBtn.disabled = !anySelected;
if (deleteBtn) deleteBtn.disabled = !anySelected;
if (zipBtn) zipBtn.disabled = !anySelected;
if (extractZipBtn) {
// Enable only if at least one selected file ends with .zip (case-insensitive).
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
chk.value.toLowerCase().endsWith(".zip")
);
extractZipBtn.disabled = !anyZipSelected;
// — Select All checkbox sync (unchanged) —
const master = document.getElementById("selectAll");
if (master) {
if (selectedCheckboxes.length === fileCheckboxes.length) {
master.checked = true;
master.indeterminate = false;
} else if (selectedCheckboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
// Delete / Copy / Move: only show when something is selected
if (deleteBtn) {
deleteBtn.style.display = anySelected ? "" : "none";
}
if (copyBtn) {
copyBtn.style.display = anySelected ? "" : "none";
}
if (moveBtn) {
moveBtn.style.display = anySelected ? "" : "none";
}
// Download ZIP: only show when something is selected
if (zipBtn) {
zipBtn.style.display = anySelected ? "" : "none";
}
// Extract ZIP: only show when a selected file is a .zip
if (extractZipBtn) {
extractZipBtn.style.display = anyZip ? "" : "none";
}
// Create File: only show when nothing is selected
if (createBtn) {
createBtn.style.display = anySelected ? "none" : "";
}
// Finally disable the ones that are shown but 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) {

View File

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

View File

@@ -3,20 +3,143 @@ import { escapeHTML, showToast } from './domUtils.js';
import { loadFileList } from './fileListView.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) {
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
switch (ext) {
case "css":
return "css";
case "json":
return { name: "javascript", json: true };
case "js":
return "javascript";
// markup
case "html":
case "htm":
return "text/html";
return "text/html"; // ensureModeLoaded will map to htmlmixed
case "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:
return "text/plain";
}
@@ -47,6 +170,7 @@ export function editFile(fileName, folder) {
if (existingEditor) {
existingEditor.remove();
}
const folderUsed = folder || window.currentFolder || "root";
const folderPath = folderUsed === "root"
? "uploads/"
@@ -55,26 +179,40 @@ export function editFile(fileName, folder) {
fetch(fileUrl, { method: "HEAD" })
.then(response => {
const contentLength = response.headers.get("Content-Length");
if (contentLength !== null && parseInt(contentLength) > 10485760) {
const lenHeader =
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.");
throw new Error("File too large.");
}
return fetch(fileUrl);
return response;
})
.then(() => fetch(fileUrl))
.then(response => {
if (!response.ok) {
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");
modal.id = "editorContainer";
modal.classList.add("modal", "editor-modal");
modal.innerHTML = `
<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">
<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>
@@ -90,61 +228,74 @@ export function editFile(fileName, folder) {
document.body.appendChild(modal);
modal.style.display = "block";
const mode = getModeForFile(fileName);
const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default";
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
lineNumbers: true,
// choose mode + lighter settings for large files
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
const cmOptions = {
lineNumbers: !forcePlainText,
mode: mode,
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(() => {
adjustEditorSize();
}, 50);
window.currentEditor = editor;
observeModalResize(modal);
setTimeout(() => {
adjustEditorSize();
}, 50);
let currentFontSize = 14;
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
observeModalResize(modal);
document.getElementById("closeEditorX").addEventListener("click", function () {
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
let currentFontSize = 14;
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
document.getElementById("closeEditorX").addEventListener("click", function () {
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("increaseFont").addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("saveBtn").addEventListener("click", function () {
saveFile(fileName, folderUsed);
});
document.getElementById("closeBtn").addEventListener("click", function () {
modal.remove();
});
function updateEditorTheme() {
const 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 { openDownloadModal } from './fileActions.js';
import { openTagModal, openMultiTagModal } from './fileTags.js';
import {
getParentFolder,
updateBreadcrumbTitle,
setupBreadcrumbDelegation,
showFolderManagerContextMenu,
hideFolderManagerContextMenu,
openRenameFolderModal,
openDeleteFolderModal
} from './folderManager.js';
import { openFolderShareModal } from './folderShareModal.js';
import {
folderDragOverHandler,
folderDragLeaveHandler,
folderDropHandler
} from './fileDragDrop.js';
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
// 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(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
10
@@ -186,171 +207,323 @@ export function formatFolderName(folder) {
window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;
export function loadFileList(folderParam) {
export async function loadFileList(folderParam) {
const reqId = ++__fileListReqSeq; // latest call wins
const folder = folderParam || "root";
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.innerHTML = "<div class='loader'>Loading files...</div>";
return fetch(
"/api/file/getFileList.php?folder=" +
encodeURIComponent(folder) +
"&recursive=1&t=" +
Date.now()
)
.then((res) =>
res.status === 401
? (window.location.href = "/api/auth/logout.php" && Promise.reject("Unauthorized"))
: res.json()
)
.then((data) => {
fileListContainer.innerHTML = "";
try {
// Kick off both in parallel, but we'll render as soon as FILES are ready
const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`);
const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`);
// No files case
if (!data.files || Object.keys(data.files).length === 0) {
fileListContainer.textContent = t("no_files_found");
// ----- FILES FIRST -----
const filesRes = await filesPromise;
// hide summary
const summaryElem = document.getElementById("fileSummary");
if (summaryElem) summaryElem.style.display = "none";
if (filesRes.status === 401) {
window.location.href = "/api/auth/logout.php";
throw new Error("Unauthorized");
}
// hide slider
const sliderContainer = document.getElementById("viewSliderContainer");
if (sliderContainer) sliderContainer.style.display = "none";
const data = await filesRes.json();
updateFileActionButtons();
return [];
}
// If another loadFileList ran after this one, bail before touching the DOM
if (reqId !== __fileListReqSeq) return [];
// Normalize to array
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
// Enrich each file
data.files = data.files.map((f) => {
f.fullName = (f.path || f.name).trim().toLowerCase();
f.editable = canEditFile(f.name);
f.folder = folder;
return f;
});
fileData = data.files;
// 3) clear loader (still only if this request is the latest)
fileListContainer.innerHTML = "";
// --- folder summary + slider injection ---
const actionsContainer = document.getElementById("fileListActions");
if (actionsContainer) {
// 1) 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);
// 4) handle “no files” case
if (!data.files || Object.keys(data.files).length === 0) {
if (reqId !== __fileListReqSeq) return [];
fileListContainer.textContent = t("no_files_found");
// 2) viewmode 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; vertical-align: middle; margin-right: auto; font-size: 0.9em;";
actionsContainer.insertBefore(sliderContainer, summaryElem);
} else {
sliderContainer.style.display = "inline-flex";
}
// hide summary + slider
const summaryElem = document.getElementById("fileSummary");
if (summaryElem) summaryElem.style.display = "none";
const sliderContainer = document.getElementById("viewSliderContainer");
if (sliderContainer) sliderContainer.style.display = "none";
if (viewMode === "gallery") {
// determine responsive caps:
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; white-space:nowrap; 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>
`;
// hookup gallery slider
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;
// update grid if already rendered
const grid = document.querySelector(".gallery-container");
if (grid) grid.style.gridTemplateColumns = `repeat(${v},1fr)`;
};
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight") ?? "48", 10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px; white-space:nowrap; line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="31" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px; line-height:1;">${currentHeight}px</span>
`;
// hookup rowheight slider
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";
};
}
}
// 3) Render based on viewMode
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
// 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();
return data.files;
})
.catch((err) => {
console.error("Error loading file list:", err);
if (err !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
return [];
})
.finally(() => {
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.
*/
export function renderFileTable(folder, container) {
export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
@@ -408,6 +581,10 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder));
});
// pagination clicks
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
@@ -996,12 +1173,64 @@ function parseCustomDate(dateStr) {
}
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 = [
"txt", "html", "htm", "css", "js", "json", "xml",
"md", "py", "ini", "csv", "log", "conf", "config", "bat",
"rtf", "doc", "docx"
// Plain text & docs (text)
"txt", "text", "md", "markdown", "rst",
// 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);
}

View File

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

View File

@@ -56,7 +56,7 @@ function saveFolderTreeState(state) {
}
// Helper for getting the parent folder.
function getParentFolder(folder) {
export function getParentFolder(folder) {
if (folder === "root") return "root";
const lastSlash = folder.lastIndexOf("/");
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
@@ -361,7 +361,7 @@ function renderBreadcrumbFragment(folderPath) {
return frag;
}
function updateBreadcrumbTitle(folder) {
export function updateBreadcrumbTitle(folder) {
const titleEl = document.getElementById("fileListTitle");
titleEl.textContent = "";
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
@@ -551,7 +551,7 @@ export function loadFolderList(selectedFolder) {
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
function openRenameFolderModal() {
export function openRenameFolderModal() {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") {
showToast("Please select a valid folder to rename.");
@@ -614,7 +614,7 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
});
});
function openDeleteFolderModal() {
export function openDeleteFolderModal() {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") {
showToast("Please select a valid folder to delete.");
@@ -718,7 +718,7 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
});
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
function showFolderManagerContextMenu(x, y, menuItems) {
export function showFolderManagerContextMenu(x, y, menuItems) {
let menu = document.getElementById("folderManagerContextMenu");
if (!menu) {
menu = document.createElement("div");
@@ -765,7 +765,7 @@ function showFolderManagerContextMenu(x, y, menuItems) {
menu.style.display = "block";
}
function hideFolderManagerContextMenu() {
export function hideFolderManagerContextMenu() {
const menu = document.getElementById("folderManagerContextMenu");
if (menu) {
menu.style.display = "none";
@@ -796,7 +796,7 @@ function folderManagerContextMenuHandler(e) {
},
{
label: t("folder_share"),
action: () => { openFolderShareModal(); }
action: () => { openFolderShareModal(folder); }
},
{
label: t("delete_folder"),

View File

@@ -266,7 +266,16 @@ const translations = {
"items_per_page": "items per page",
"columns": "Columns",
"row_height": "Row Height",
"api_docs": "API Docs"
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
"enter_file_name": "Enter file name",
"newfile_placeholder": "New file name",
"file_created_successfully": "File created successfully!",
"error_creating_file": "Error creating file",
"file_created": "File created successfully!"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -20,6 +20,30 @@ export function initializeApp() {
window.currentFolder = "root";
initTagSearch();
loadFileList(window.currentFolder);
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
fileListArea.addEventListener('dragover', e => {
e.preventDefault();
fileListArea.classList.add('drop-hover');
});
fileListArea.addEventListener('dragleave', () => {
fileListArea.classList.remove('drop-hover');
});
fileListArea.addEventListener('drop', e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
// re-dispatch the same drop into the real upload card
uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer,
bubbles: true,
cancelable: true
}));
});
}
initDragAndDrop();
loadSidebarOrder();
loadHeaderOrder();
@@ -29,46 +53,37 @@ export function initializeApp() {
setupTrashRestoreDelete();
loadAdminConfigFunc();
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
if (helpBtn && helpTooltip) {
helpBtn.addEventListener("click", () => {
helpTooltip.style.display =
helpTooltip.style.display === "block" ? "none" : "block";
});
}
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
if (helpBtn && helpTooltip) {
helpBtn.addEventListener("click", () => {
helpTooltip.style.display =
helpTooltip.style.display === "block" ? "none" : "block";
});
}
}
export function loadCsrfToken() {
return fetchWithCsrf('/api/auth/token.php', {
method: 'GET'
})
return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
.then(res => {
if (!res.ok) {
throw new Error(`Token fetch failed with status ${res.status}`);
}
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
return res.json();
})
.then(({ csrf_token, share_url }) => {
// Update global and <meta>
window.csrfToken = csrf_token;
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
// update CSRF meta
let meta = document.querySelector('meta[name="csrf-token"]') ||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
meta.content = csrf_token;
let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
shareMeta.content = share_url;
// force share_url to match wherever we're browsing
const actualShare = window.location.origin;
let shareMeta = document.querySelector('meta[name="share-url"]') ||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
shareMeta.content = actualShare;
return { csrf_token, share_url };
return { csrf_token, share_url: actualShare };
});
}
@@ -85,8 +100,8 @@ export function triggerLogout() {
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
})
.then(() => window.location.reload(true))
.catch(()=>{});
.then(() => window.location.reload(true))
.catch(() => { });
}
@@ -120,10 +135,10 @@ document.addEventListener("DOMContentLoaded", function () {
// Continue with initializations that rely on a valid CSRF token:
checkAuthentication().then(authenticated => {
if (authenticated) {
const overlay = document.getElementById('loadingOverlay');
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
initializeApp();
}
}
});
// Other DOM initialization that can happen after CSRF is ready.

View File

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

View File

@@ -669,6 +669,18 @@ function submitFiles(allFiles) {
}
allSucceeded = false;
}
if (file.isClipboard) {
setTimeout(() => {
window.selectedFiles = [];
updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer");
if (progressContainer) progressContainer.innerHTML = "";
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
}, 5000);
}
// ─── Only now count this chunk as finished ───────────────────
finishedCount++;
@@ -847,4 +859,39 @@ function initUpload() {
}
}
export { initUpload };
export { initUpload };
// -------------------------
// Clipboard Paste Handler (Mimics Drag-and-Drop)
// -------------------------
document.addEventListener('paste', function handlePasteUpload(e) {
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const ext = file.name.split('.').pop() || 'png';
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
renamedFile.isClipboard = true;
Object.defineProperty(renamedFile, 'customRelativePath', {
value: renamedFile.name,
writable: true,
configurable: true
});
files.push(renamedFile);
}
}
}
if (files.length > 0) {
processFiles(files);
showToast('Pasted file added to upload list.', 'success');
}
});

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;
}
$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']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
@@ -180,7 +180,7 @@ class AdminController
$merged['loginOptions'] = $existing['loginOptions'] ?? [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin'=> false,
'disableOIDCLogin'=> true,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
];

View File

@@ -1626,4 +1626,31 @@ class FileController
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.
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
// Optionally sanitize the parent.
if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid parent folder name.']);
exit;
}
@@ -340,16 +342,14 @@ class FolderController
public function getFolderList(): void
{
header('Content-Type: application/json');
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Optionally, you might add further input validation if necessary.
$folderList = FolderModel::getFolderList();
$parent = $_GET['folder'] ?? null;
$folderList = FolderModel::getFolderList($parent);
echo json_encode($folderList);
exit;
}
@@ -1087,11 +1087,11 @@ class FolderController
header('Content-Type: application/json');
$shareFile = META_DIR . 'share_folder_links.json';
$links = file_exists($shareFile)
? json_decode(file_get_contents($shareFile), true) ?? []
: [];
? json_decode(file_get_contents($shareFile), true) ?? []
: [];
$now = time();
$cleaned = [];
// 1) Remove expired
foreach ($links as $token => $record) {
if (!empty($record['expires']) && $record['expires'] < $now) {
@@ -1099,12 +1099,12 @@ class FolderController
}
$cleaned[$token] = $record;
}
// 2) Persist back if anything was pruned
if (count($cleaned) !== count($links)) {
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
}
echo json_encode($cleaned);
}

View File

@@ -35,14 +35,19 @@ class AdminModel
*/
public static function updateConfig(array $configUpdate): array
{
// Validate required OIDC configuration keys.
if (
empty($configUpdate['oidc']['providerUrl']) ||
empty($configUpdate['oidc']['clientId']) ||
empty($configUpdate['oidc']['clientSecret']) ||
empty($configUpdate['oidc']['redirectUri'])
) {
return ["error" => "Incomplete OIDC configuration."];
// New: only enforce OIDC fields when OIDC is enabled
$oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
: true; // default to disabled when not present
if (!$oidcDisabled) {
$oidc = $configUpdate['oidc'] ?? [];
$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)
@@ -111,6 +116,8 @@ class AdminModel
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."];
}
@@ -137,17 +144,34 @@ class AdminModel
// Normalize login options if missing
if (!isset($config['loginOptions'])) {
// migrate legacy top-level flags; default OIDC to true (disabled)
$config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : 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']);
} else {
// Ensure proper boolean types
$config['loginOptions']['disableFormLogin'] = (bool)$config['loginOptions']['disableFormLogin'];
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
// normalize booleans; default OIDC to true (disabled) if missing
$lo = &$config['loginOptions'];
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
$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'])) {
@@ -193,7 +217,7 @@ class AdminModel
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => false
'disableOIDCLogin' => true
],
'globalOtpauthUrl' => "",
'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".
*/
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';
// Determine the target directory.
if (strtolower($folder) !== 'root') {
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
} else {
$directory = UPLOAD_DIR;
}
// Validate folder.
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."];
}
// Helper: Build the metadata file path.
$getMetadataFilePath = function(string $folder): string {
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';
};
$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)) {
return ["error" => "Directory not found."];
}
$allFiles = array_values(array_diff(scandir($directory), array('.', '..')));
$fileList = [];
// Define a safe file name pattern.
$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) {
if (substr($file, 0, 1) === '.') {
continue; // Skip hidden files.
if ($file === '' || $file[0] === '.') {
continue; // Skip hidden/invalid entries.
}
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
if (!is_file($filePath)) {
continue; // Only process files.
@@ -1212,13 +1220,17 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
if (!preg_match($safeFileNamePattern, $file)) {
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;
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "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) {
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
} elseif ($fileSizeBytes >= 1048576) {
@@ -1228,29 +1240,65 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
} else {
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
}
$fileEntry = [
'name' => $file,
'modified' => $fileDateModified,
'uploaded' => $fileUploadedDate,
'size' => $fileSizeFormatted,
'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;
// MIME + text detection (fallback to extension)
$mime = 'application/octet-stream';
if ($finfo) {
$det = @finfo_file($finfo, $filePath);
if (is_string($det) && $det !== '') $mime = $det;
}
$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;
}
if ($finfo) { @finfo_close($finfo); }
// Load global tags.
$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];
}
@@ -1278,4 +1326,64 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
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];
}
}

105
start.sh
View File

@@ -1,35 +1,67 @@
#!/bin/bash
set -euo pipefail
umask 002
echo "🚀 Running start.sh..."
# 1) Tokenkey warning
if [ "${PERSISTENT_TOKENS_KEY}" = "default_please_change_this_key" ]; then
echo "⚠️ WARNING: Using default persistent tokens key—override for production."
# ──────────────────────────────────────────────────────────────
# 0) If NOT root, we can't remap/chown. Log a hint and skip those parts.
# 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
# 2) Update config.php based on environment variables
CONFIG_FILE="/var/www/config/config.php"
if [ -f "${CONFIG_FILE}" ]; then
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}"
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
fi
[ -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}"
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
# NOTE: SHARE_URL is read from getenv in PHP; no sed needed.
fi
# 2.1) Prepare metadata/log for Apache logs
# 2.1) Prepare metadata/log & sessions
mkdir -p /var/www/metadata/log
chown www-data:www-data /var/www/metadata/log
chmod 775 /var/www/metadata/log
chown www-data:www-data /var/www/metadata/log
chmod 775 /var/www/metadata/log
mkdir -p /var/www/sessions
chown www-data:www-data /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
tgt="/var/www/${d}"
mkdir -p "${tgt}"
@@ -37,7 +69,7 @@ for d in uploads users metadata; do
chmod 775 "${tgt}"
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
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
@@ -49,8 +81,7 @@ fi
# 4) Adjust Apache LimitRequestBody
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
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
m) factor=$((1024*1024)); num=${size_str%m} ;;
@@ -73,29 +104,22 @@ EOF
# 6) Override ports if provided
if [ -n "${HTTP_PORT:-}" ]; then
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.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 || true
fi
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
# 7) Set ServerName
if [ -n "${SERVER_NAME:-}" ]; then
echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
# 7) Set ServerName (idempotent)
SN="${SERVER_NAME:-FileRise}"
if grep -qE '^ServerName\s' /etc/apache2/apache2.conf; then
sed -i "s|^ServerName .*|ServerName ${SN}|" /etc/apache2/apache2.conf
else
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
echo "ServerName ${SN}" >> /etc/apache2/apache2.conf
fi
# 8) Prepare dynamic data directories with least privilege
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
# 8) Initialize persistent files if absent
if [ ! -f /var/www/users/users.txt ]; then
echo "" > /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
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..."
exec apachectl -D FOREGROUND
exec apachectl -D FOREGROUND