Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd5a8664a | ||
|
|
0cb47b4054 | ||
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 |
75
CHANGELOG.md
75
CHANGELOG.md
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 10/6/2025 v1.3.15
|
||||
|
||||
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
|
||||
|
||||
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
|
||||
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
|
||||
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
|
||||
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/5/2025 v1.3.14
|
||||
|
||||
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||
|
||||
- AdminModel::updateConfig now enforces OIDC fields only if disableOIDCLogin=false
|
||||
- AdminModel::getConfig defaults disableOIDCLogin=true and guarantees OIDC keys
|
||||
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
|
||||
- Normalize file perms to 0664 after write
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.13
|
||||
|
||||
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||
fix(scanner): rebuild per-folder metadata to match File/Folder models
|
||||
chore(scanner): skip profile_pics subtree during scans
|
||||
|
||||
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
|
||||
- prevents double slashes in metadata paths; respects app timezone
|
||||
- unblocks SCAN_ON_START so externally added files are indexed at boot
|
||||
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
|
||||
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
|
||||
- Prunes stale entries for files that no longer exist
|
||||
- Skips uploads/trash and symlinks
|
||||
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
|
||||
- Idempotent; safe to run at startup via SCAN_ON_START
|
||||
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.12
|
||||
|
||||
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||
|
||||
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
|
||||
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||
- SCAN_ON_START unchanged, with non-root fallback
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.11
|
||||
|
||||
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||
|
||||
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.10
|
||||
|
||||
Fix: index externally added files on startup; harden start.sh (#46)
|
||||
|
||||
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
|
||||
- Execute scan as www-data; continue on failure so startup isn’t blocked
|
||||
- Guard env reads for set -u; add umask 002 for consistent 775/664
|
||||
- Make ServerName idempotent; avoid duplicate entries
|
||||
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
|
||||
|
||||
No behavior change unless SCAN_ON_START=true.
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/27/2025 v1.3.9
|
||||
|
||||
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||
|
||||
128
README.md
128
README.md
@@ -52,38 +52,61 @@ Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (
|
||||
|
||||
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
|
||||
|
||||
### 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 server’s IP). The above example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “remember me” tokens) – be sure to change it to a random string for security.
|
||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP).
|
||||
The example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “Remember Me” tokens)—change it to a strong random string.
|
||||
|
||||
**First-time Setup:** On first launch, FileRise will detect no users and prompt you to create an **Admin account**. Choose your admin username & password, and you’re in! You can then head to the **User Management** section to add additional users if needed.
|
||||
**First-time Setup**
|
||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. After logging in, use **User Management** to add more users.
|
||||
|
||||
### 2. Manual Installation (PHP/Apache)
|
||||
---
|
||||
|
||||
### 2) Manual Installation (PHP/Apache)
|
||||
|
||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
||||
|
||||
- **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 server’s directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
||||
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||
|
||||
- **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
|
||||
**Composer (if applicable)**
|
||||
If you use optional features requiring Composer libraries, run:
|
||||
|
||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
``` bash
|
||||
**Folders & Permissions**
|
||||
|
||||
```bash
|
||||
mkdir -p uploads users metadata
|
||||
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 PHP’s `upload_max_filesize` and `post_max_size` to at least this value (the Docker start script auto-adjusts PHP limits).
|
||||
|
||||
- `PERSISTENT_TOKENS_KEY` – set a unique secret if you use “Remember Me” logins, to encrypt the tokens.
|
||||
|
||||
- Other settings like `UPLOAD_DIR`, `USERS_FILE` etc. generally don’t need changes unless you move those folders. Defaults are set for the directories mentioned above.
|
||||
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, you’ll need to replicate those protections (see Wiki: [Nginx Setup for examples](https://github.com/error311/FileRise/wiki/Nginx-Setup)). Also enable mod_rewrite if not already, as FileRise may use pretty URLs for share links.
|
||||
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||
- `TOTAL_UPLOAD_SIZE` (also ensure your PHP `upload_max_filesize` & `post_max_size` meet/exceed this).
|
||||
- `PERSISTENT_TOKENS_KEY` set to a unique secret if using “Remember Me”.
|
||||
|
||||
Now navigate to the FileRise URL in your browser. On first load, you’ll be prompted to create the Admin user (same as Docker setup). After that, the application is ready to use!
|
||||
**Share links base URL**
|
||||
|
||||
- You can set **`SHARE_URL`** via your web server environment variables (preferred),
|
||||
**or** keep using `BASE_URL` in `config.php` as a fallback for manual installs.
|
||||
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||
|
||||
**Web Server Config**
|
||||
|
||||
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||
- Nginx/other: replicate the basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||
|
||||
Now browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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.9";
|
||||
const version = "v1.3.15";
|
||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||
|
||||
// ————— Inject updated styles —————
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
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
|
||||
@@ -202,51 +208,39 @@ window.toggleRowSelection = toggleRowSelection;
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
|
||||
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
|
||||
// 1) show loader (only this request is allowed to render)
|
||||
fileListContainer.style.visibility = "hidden";
|
||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||
|
||||
try {
|
||||
// 2) fetch files + folders in parallel
|
||||
const [filesRes, foldersRes] = await Promise.all([
|
||||
fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
|
||||
fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
|
||||
]);
|
||||
// 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)}`);
|
||||
|
||||
// ----- FILES FIRST -----
|
||||
const filesRes = await filesPromise;
|
||||
|
||||
if (filesRes.status === 401) {
|
||||
window.location.href = "/api/auth/logout.php";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const data = await filesRes.json();
|
||||
const folderRaw = await foldersRes.json();
|
||||
|
||||
// --- 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));
|
||||
// If another loadFileList ran after this one, bail before touching the DOM
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
|
||||
// 3) clear loader
|
||||
// 3) clear loader (still only if this request is the latest)
|
||||
fileListContainer.innerHTML = "";
|
||||
|
||||
// 4) handle “no files” case
|
||||
if (!data.files || Object.keys(data.files).length === 0) {
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
fileListContainer.textContent = t("no_files_found");
|
||||
|
||||
// hide summary + slider
|
||||
@@ -255,36 +249,12 @@ export async function loadFileList(folderParam) {
|
||||
const sliderContainer = document.getElementById("viewSliderContainer");
|
||||
if (sliderContainer) sliderContainer.style.display = "none";
|
||||
|
||||
// show/hide folder strip *even when there are no files*
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
strip.id = "folderStripContainer";
|
||||
strip.className = "folder-strip-container";
|
||||
actionsContainer.parentNode.insertBefore(strip, fileListContainer);
|
||||
}
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}">
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
strip.style.display = "flex";
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
loadFileList(dest);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
// hide folder strip for now; we’ll re-show it after folders load (below)
|
||||
const strip = document.getElementById("folderStripContainer");
|
||||
if (strip) strip.style.display = "none";
|
||||
|
||||
updateFileActionButtons();
|
||||
fileListContainer.style.visibility = "visible";
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -295,14 +265,49 @@ export async function loadFileList(folderParam) {
|
||||
return meta;
|
||||
});
|
||||
}
|
||||
|
||||
data.files = data.files.map(f => {
|
||||
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||||
f.editable = canEditFile(f.name);
|
||||
|
||||
// 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
|
||||
@@ -342,19 +347,19 @@ export async function loadFileList(folderParam) {
|
||||
);
|
||||
|
||||
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>
|
||||
`;
|
||||
<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 => {
|
||||
@@ -367,12 +372,12 @@ export async function loadFileList(folderParam) {
|
||||
} 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>
|
||||
`;
|
||||
<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 => {
|
||||
@@ -384,93 +389,121 @@ export async function loadFileList(folderParam) {
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 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);
|
||||
}
|
||||
// 7) render files (only if still latest)
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}" draggable="true">
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
strip.style.display = "flex";
|
||||
|
||||
// wire up each folder‐tile
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
||||
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
// highlight the strip tile
|
||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
// reuse folderManager menu
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => openRenameFolderModal()
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => openFolderShareModal(dest)
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => openDeleteFolderModal()
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
});
|
||||
});
|
||||
|
||||
// one global click to hide any open context menu
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
|
||||
// 8) render files
|
||||
if (window.viewMode === "gallery") {
|
||||
renderGalleryView(folder);
|
||||
} else {
|
||||
renderFileTable(folder);
|
||||
}
|
||||
|
||||
updateFileActionButtons();
|
||||
fileListContainer.style.visibility = "visible";
|
||||
|
||||
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||
try {
|
||||
const foldersRes = await foldersPromise;
|
||||
const folderRaw = await foldersRes.json();
|
||||
if (reqId !== __fileListReqSeq) return data.files;
|
||||
|
||||
// --- build ONLY the *direct* children of current folder ---
|
||||
let subfolders = [];
|
||||
const hidden = new Set(["profile_pics", "trash"]);
|
||||
if (Array.isArray(folderRaw)) {
|
||||
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||||
subfolders = allPaths
|
||||
.filter(p => {
|
||||
if (folder === "root") return p.indexOf("/") === -1;
|
||||
if (!p.startsWith(folder + "/")) return false;
|
||||
return p.split("/").length === depth;
|
||||
})
|
||||
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||||
}
|
||||
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||
|
||||
// inject folder strip below actions, above file list
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
strip.id = "folderStripContainer";
|
||||
strip.className = "folder-strip-container";
|
||||
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||||
}
|
||||
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}" draggable="true">
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
strip.style.display = "flex";
|
||||
|
||||
// wire up each folder‐tile
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
||||
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
// highlight the strip tile
|
||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
// reuse folderManager menu
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => openRenameFolderModal()
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => openFolderShareModal(dest)
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => openDeleteFolderModal()
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
});
|
||||
});
|
||||
|
||||
// one global click to hide any open context menu
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
} catch {
|
||||
// ignore folder errors; rows already rendered
|
||||
}
|
||||
|
||||
return data.files;
|
||||
|
||||
} catch (err) {
|
||||
@@ -480,7 +513,10 @@ export async function loadFileList(folderParam) {
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
fileListContainer.style.visibility = "visible";
|
||||
// Only the latest call should restore visibility
|
||||
if (reqId === __fileListReqSeq) {
|
||||
fileListContainer.style.visibility = "visible";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* scan_uploads.php
|
||||
* Scans the uploads directory and creates metadata entries for new files/folders using config settings.
|
||||
* 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';
|
||||
|
||||
if (!isset($config['upload_dir']) || !isset($config['metadata_dir'])) {
|
||||
die("Missing configuration for upload_dir or metadata_dir\n");
|
||||
// ---------- 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';
|
||||
}
|
||||
|
||||
$uploadDir = $config['upload_dir'];
|
||||
$metadataDir = $config['metadata_dir'];
|
||||
date_default_timezone_set('UTC');
|
||||
/** 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;
|
||||
}
|
||||
|
||||
function scanDirectory($dir) {
|
||||
$items = array_diff(scandir($dir), ['.', '..']);
|
||||
$results = [];
|
||||
/** 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;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
$results[] = $path;
|
||||
/** Recursively list subfolders (relative folder keys), skipping trash/. */
|
||||
function list_all_folders(string $root): array {
|
||||
$root = rtrim($root, '/\\');
|
||||
$folders = ['root'];
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($it as $path => $info) {
|
||||
if ($info->isDir()) {
|
||||
// relative key like "foo/bar"
|
||||
$rel = ltrim(str_replace(['\\'], '/', substr($path, strlen($root) + 1)), '/');
|
||||
if ($rel === '') continue;
|
||||
// skip trash and profile_pics subtrees
|
||||
if ($rel === 'trash' || strpos($rel, 'trash/') === 0) continue;
|
||||
if ($rel === 'profile_pics' || strpos($rel, 'profile_pics/') === 0) continue;
|
||||
// obey the app’s folder-name regex to stay consistent
|
||||
if (preg_match(REGEX_FOLDER_NAME, basename($rel))) {
|
||||
$folders[] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
// de-dup and sort
|
||||
$folders = array_values(array_unique($folders));
|
||||
sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $folders;
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
$results = array_merge($results, scanDirectory($path));
|
||||
// ---------- 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;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
function metadataPath($filePath, $uploadDir, $metadataDir) {
|
||||
$relativePath = ltrim(str_replace($uploadDir, '', $filePath), '/');
|
||||
return $metadataDir . '/' . $relativePath . '.json';
|
||||
}
|
||||
|
||||
$allItems = scanDirectory($uploadDir);
|
||||
|
||||
foreach ($allItems as $item) {
|
||||
$metaPath = metadataPath($item, $uploadDir, $metadataDir);
|
||||
|
||||
if (!file_exists($metaPath)) {
|
||||
$type = is_dir($item) ? 'folder' : 'file';
|
||||
$size = is_file($item) ? filesize($item) : 0;
|
||||
|
||||
$metadata = [
|
||||
'path' => str_replace($uploadDir, '', $item),
|
||||
'type' => $type,
|
||||
'size' => $size,
|
||||
'user' => 'Imported',
|
||||
'uploadDate' => date('c')
|
||||
];
|
||||
|
||||
if (!is_dir(dirname($metaPath))) {
|
||||
mkdir(dirname($metaPath), 0775, true);
|
||||
// PRUNE stale metadata entries for files that no longer exist
|
||||
foreach ($existing as $name) {
|
||||
if (!in_array($name, $files, true)) {
|
||||
unset($metadata[$name]);
|
||||
$totalPruned++;
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT));
|
||||
echo "Created metadata for: {$item}\n";
|
||||
// Ensure parent dir exists and write metadata
|
||||
@mkdir(dirname($metaPath), 0775, true);
|
||||
if (@file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||
fwrite(STDERR, "Failed to write metadata for folder: {$folderKey}\n");
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
echo "Done. Created {$totalCreated} entr" . ($totalCreated === 1 ? "y" : "ies") .
|
||||
", pruned {$totalPruned}.\n";
|
||||
|
||||
@@ -150,7 +150,7 @@ class AdminController
|
||||
exit;
|
||||
}
|
||||
$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'
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
108
start.sh
108
start.sh
@@ -1,35 +1,67 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 002
|
||||
echo "🚀 Running start.sh..."
|
||||
|
||||
# 1) Token‐key 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) Token‐key warning (guarded for -u)
|
||||
if [ "${PERSISTENT_TOKENS_KEY:-}" = "default_please_change_this_key" ] || [ -z "${PERSISTENT_TOKENS_KEY:-}" ]; then
|
||||
echo "⚠️ WARNING: Using default/empty persistent tokens key—override for production."
|
||||
fi
|
||||
|
||||
# 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,10 +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
|
||||
|
||||
if [ "$SCAN_ON_START" = "true" ]; then
|
||||
echo "Scanning uploads directory to generate metadata..."
|
||||
php /var/www/scripts/scan_uploads.php
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user