Compare commits

...

8 Commits

11 changed files with 948 additions and 382 deletions

View File

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

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

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

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

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

@@ -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; well 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 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";
}
// 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 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) {
@@ -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);
}

View File

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

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

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

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