Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ce6a76be | ||
|
|
f2ab2a96bc | ||
|
|
c22c8e0f34 | ||
|
|
070515e7a6 | ||
|
|
7a0f4ddbb4 | ||
|
|
e1c15eb95a | ||
|
|
2400dcb9eb | ||
|
|
c717f8be60 | ||
|
|
3dd5a8664a | ||
|
|
0cb47b4054 | ||
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 | ||
|
|
4f39b3a41e | ||
|
|
40cecc10ad | ||
|
|
aee78c9750 |
@@ -12,3 +12,9 @@ tmp/
|
||||
.env
|
||||
.vscode/
|
||||
.DS_Store
|
||||
data/
|
||||
uploads/
|
||||
users/
|
||||
metadata/
|
||||
sessions/
|
||||
vendor/
|
||||
|
||||
92
.github/workflows/ci.yml
vendored
Normal file
92
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: CI
|
||||
"on":
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
php-lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
- name: Validate composer.json (if present)
|
||||
run: |
|
||||
if [ -f composer.json ]; then composer validate --no-check-publish; fi
|
||||
- name: Composer audit (if lock present)
|
||||
run: |
|
||||
if [ -f composer.lock ]; then composer audit || true; fi
|
||||
- name: PHP syntax check
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t files < <(git ls-files '*.php')
|
||||
if [ "${#files[@]}" -gt 0 ]; then
|
||||
for f in "${files[@]}"; do php -l "$f"; done
|
||||
else
|
||||
echo "No PHP files found."
|
||||
fi
|
||||
|
||||
shellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: ShellCheck all scripts
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t sh < <(git ls-files '*.sh')
|
||||
if [ "${#sh[@]}" -gt 0 ]; then
|
||||
shellcheck "${sh[@]}"
|
||||
else
|
||||
echo "No shell scripts found."
|
||||
fi
|
||||
|
||||
dockerfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint Dockerfile with hadolint
|
||||
uses: hadolint/hadolint-action@v3.1.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
failure-threshold: error
|
||||
ignore: DL3008,DL3059
|
||||
|
||||
sanity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y jq yamllint
|
||||
- name: Lint JSON
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t jsons < <(git ls-files '*.json' ':!:vendor/**')
|
||||
if [ "${#jsons[@]}" -gt 0 ]; then
|
||||
for j in "${jsons[@]}"; do jq -e . "$j" >/dev/null; done
|
||||
else
|
||||
echo "No JSON files."
|
||||
fi
|
||||
- name: Lint YAML
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t yamls < <(git ls-files '*.yml' '*.yaml')
|
||||
if [ "${#yamls[@]}" -gt 0 ]; then
|
||||
yamllint -d "{extends: default, rules: {line-length: disable, truthy: {check-keys: false}}}" "${yamls[@]}"
|
||||
else
|
||||
echo "No YAML files."
|
||||
fi
|
||||
3
.github/workflows/sync-changelog.yml
vendored
3
.github/workflows/sync-changelog.yml
vendored
@@ -1,3 +1,4 @@
|
||||
---
|
||||
name: Sync Changelog to Docker Repo
|
||||
|
||||
on:
|
||||
@@ -40,4 +41,4 @@ jobs:
|
||||
else
|
||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
||||
git push origin main
|
||||
fi
|
||||
fi
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/data/
|
||||
233
CHANGELOG.md
233
CHANGELOG.md
@@ -1,5 +1,228 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 10/15/2025 (v1.4.0)
|
||||
|
||||
feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend
|
||||
|
||||
### Security / Hardening
|
||||
|
||||
- Tightened ownership checks across file ops; introduced centralized permission helper to avoid falsey-permissions bugs.
|
||||
- Consistent CSRF verification on mutating endpoints; stricter input validation using `REGEX_*` and `basename()` trims.
|
||||
- Safer path handling & metadata reads; reduced noisy error surfaces; consistent HTTP codes (401/403/400/500).
|
||||
- Adds defense-in-depth to reduce risk of unauthorized file manipulation.
|
||||
|
||||
### Config (`config.php`)
|
||||
|
||||
- Add optional defaults for new permissions (all optional):
|
||||
- `DEFAULT_BYPASS_OWNERSHIP` (bool)
|
||||
- `DEFAULT_CAN_SHARE` (bool)
|
||||
- `DEFAULT_CAN_ZIP` (bool)
|
||||
- `DEFAULT_VIEW_OWN_ONLY` (bool)
|
||||
- Keep existing behavior unless explicitly enabled (bypassOwnership typically true for admins; configurable per user).
|
||||
|
||||
### Controllers
|
||||
|
||||
#### `FileController.php`
|
||||
|
||||
- New lightweight `loadPerms($username)` helper that **always** returns an array; prevents type errors when permissions are missing.
|
||||
- Ownership checks now respect: `isAdmin(...) || perms['bypassOwnership'] || DEFAULT_BYPASS_OWNERSHIP`.
|
||||
- Gate sharing/zip operations by `perms['canShare']` / `perms['canZip']`.
|
||||
- Implement `viewOwnOnly` filtering in `getFileList()` (supports both map and list shapes).
|
||||
- Normalize and validate folder/file input; enforce folder-only scope for writes/moves/copies where applicable.
|
||||
- Improve error handling: convert warnings/notices to exceptions within try/catch; consistent JSON error payloads.
|
||||
- Add missing `require_once PROJECT_ROOT . '/src/models/UserModel.php'` to fix “Class userModel not found”.
|
||||
- Download behavior: inline for images, attachment for others; owner/bypass logic applied.
|
||||
|
||||
#### `FolderController.php`
|
||||
|
||||
- `createShareFolderLink()` gated by `canShare`; validates duration (cap at 1y), folder names, password optional.
|
||||
- (If present) folder share deletion/read endpoints wired to new permission model.
|
||||
|
||||
#### `AdminController.php`
|
||||
|
||||
- `getConfig()` remains admin-only; returns safe subset. (Non-admins now simply receive 403; client can ignore.)
|
||||
|
||||
#### `UserController.php`
|
||||
|
||||
- Plumbs new permission fields in get/set endpoints (`folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**).
|
||||
- Normalizes username keys and defaults to prevent undefined-index errors.
|
||||
|
||||
### Models
|
||||
|
||||
#### `FileModel.php` / `FolderModel.php`
|
||||
|
||||
- Respect caller’s effective permissions (controllers pass-through); stricter input normalization.
|
||||
- ZIP creation/extraction guarded via `canZip`; metadata updates consistent; safer temp paths.
|
||||
- Improved return shapes and error messages (never return non-array on success paths).
|
||||
|
||||
#### `AdminModel.php`
|
||||
|
||||
- Reads/writes admin config with new `loginOptions` intact; never exposes sensitive OIDC secrets to the client layer.
|
||||
|
||||
#### `UserModel.php`
|
||||
|
||||
- Store/load the 4 new flags; helper ensures absent users/fields don’t break caller; returns normalized arrays.
|
||||
|
||||
### Frontend
|
||||
|
||||
#### `main.js`
|
||||
|
||||
- Initialize after CSRF; keep dark-mode persistence, welcome toast, drag-over UX.
|
||||
- Leaves `loadAdminConfigFunc()` call in place (non-admins may 403; harmless).
|
||||
|
||||
#### `adminPanel.js` (v1.4.0)
|
||||
|
||||
- New **User Permissions** UI with collapsible rows per user:
|
||||
- Shows username; clicking expands a checkbox matrix.
|
||||
- Permissions: `folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**.
|
||||
- **Manage Shared Links** section reads folder & file share metadata; delete buttons per token.
|
||||
- Refined modal sizing & dark-mode styling; consistent toasts; unsaved-changes confirmation.
|
||||
- Keeps 403 from `/api/admin/getConfig.php` for non-admins (acceptable; no UI break).
|
||||
|
||||
### Breaking change
|
||||
|
||||
- Non-admin users without `bypassOwnership` can no longer create/rename/move/copy/delete/share/zip files they don’t own.
|
||||
- If legacy behavior depended on broad access, set `bypassOwnership` per user or use `DEFAULT_BYPASS_OWNERSHIP=true` in `config.php`.
|
||||
|
||||
### Migration
|
||||
|
||||
- Add the new flags to existing users in your permissions store (or rely on `config.php` defaults).
|
||||
- Verify admin accounts have either `isAdmin` or `bypassOwnership`/`canShare`/`canZip` as desired.
|
||||
- Optionally tune `DEFAULT_*` constants for instance-wide defaults.
|
||||
|
||||
### Security
|
||||
|
||||
- Hardened access controls for file operations based on an external security report.
|
||||
Details are withheld temporarily to protect users; a full advisory will follow after wider adoption of the fix.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/8/2025 (no new version)
|
||||
|
||||
chore: set up CI, add compose, tighten ignores, refresh README
|
||||
|
||||
- CI: add workflow to lint PHP (php -l), validate/audit composer,
|
||||
shellcheck *.sh, hadolint Dockerfile, and sanity-check JSON/YAML; supports
|
||||
push/PR/manual dispatch.
|
||||
- Docker: add docker-compose.yml for local dev (8080:80, volumes/env).
|
||||
- .dockerignore: exclude VCS, build artifacts, OS/editor junk, logs, temp dirs,
|
||||
node_modules, resources/, etc. to slim build context.
|
||||
- .gitignore: ignore .env, editor/system files, build caches, optional data/.
|
||||
- README: update badges (CI, release, license), inline demo creds, add quick
|
||||
links, tighten WebDAV section (Windows HTTPS note + wiki link), reduced length and star
|
||||
history chart.
|
||||
|
||||
## Changes 10/7/2025 (no new version)
|
||||
|
||||
feat(startup): stream error.log to console by default; add LOG_STREAM selector
|
||||
|
||||
- Touch error/access logs on start so tail can attach immediately
|
||||
- Add LOG_STREAM=error|access|both|none (default: error)
|
||||
- Tail with `-n0 -F` to follow new entries only and survive rotations
|
||||
- Keep access.log on disk but don’t spam console unless requested
|
||||
- (Unraid) Optional env var template entry for LOG_STREAM
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/6/2025 v1.3.15
|
||||
|
||||
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
|
||||
|
||||
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
|
||||
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
|
||||
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
|
||||
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/5/2025 v1.3.14
|
||||
|
||||
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||
|
||||
- AdminModel::updateConfig now enforces OIDC fields only if disableOIDCLogin=false
|
||||
- AdminModel::getConfig defaults disableOIDCLogin=true and guarantees OIDC keys
|
||||
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
|
||||
- Normalize file perms to 0664 after write
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.13
|
||||
|
||||
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||
fix(scanner): rebuild per-folder metadata to match File/Folder models
|
||||
chore(scanner): skip profile_pics subtree during scans
|
||||
|
||||
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
|
||||
- prevents double slashes in metadata paths; respects app timezone
|
||||
- unblocks SCAN_ON_START so externally added files are indexed at boot
|
||||
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
|
||||
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
|
||||
- Prunes stale entries for files that no longer exist
|
||||
- Skips uploads/trash and symlinks
|
||||
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
|
||||
- Idempotent; safe to run at startup via SCAN_ON_START
|
||||
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.12
|
||||
|
||||
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||
|
||||
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
|
||||
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||
- SCAN_ON_START unchanged, with non-root fallback
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.11
|
||||
|
||||
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||
|
||||
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.10
|
||||
|
||||
Fix: index externally added files on startup; harden start.sh (#46)
|
||||
|
||||
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
|
||||
- Execute scan as www-data; continue on failure so startup isn’t blocked
|
||||
- Guard env reads for set -u; add umask 002 for consistent 775/664
|
||||
- Make ServerName idempotent; avoid duplicate entries
|
||||
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
|
||||
|
||||
No behavior change unless SCAN_ON_START=true.
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/27/2025 v1.3.9
|
||||
|
||||
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||
- New `scripts/scan_uploads.php` script to generate metadata for imported files and folders
|
||||
- `SCAN_ON_START` environment variable to trigger automatic scanning on container startup
|
||||
- Documentation for configuring CIFS share mounting and scanning
|
||||
|
||||
- Clipboard Paste Upload Support (single image):
|
||||
- Users can now paste images directly into the FileRise web interface.
|
||||
- Pasted images are renamed to `image<TIMESTAMP>.png` and added to the upload queue using the existing drag-and-drop logic.
|
||||
- Implemented using a `.isClipboard` flag and a delayed UI cleanup inside `xhr.addEventListener("load", ...)`.
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/26/2025
|
||||
|
||||
- Updated `REGEX_FOLDER_NAME` in `config.php` to forbids < > : " | ? * characters in folder names.
|
||||
- Ensures the whole name can’t end in a space or period.
|
||||
- Blocks Windows device names.
|
||||
|
||||
- Updated `FolderController.php` when `createFolder` issues invalid folder name to return `http_response_code(400);`
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/23/2025 v1.3.8
|
||||
|
||||
- **Folder-strip context menu**
|
||||
@@ -69,7 +292,7 @@
|
||||
|
||||
- **Folder strip in file list**
|
||||
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
|
||||
- Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`.
|
||||
- Filters to only direct children of the current folder, hiding `profile_pics` and `trash`.
|
||||
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
|
||||
- Clicking a folder in the strip updates:
|
||||
- the breadcrumb (via `updateBreadcrumbTitle`)
|
||||
@@ -117,7 +340,7 @@
|
||||
|
||||
- Moved previously standalone header buttons into the dropdown menu:
|
||||
- **User Panel** opens the modal
|
||||
- **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net`
|
||||
- **Admin Panel** only shown when `data.isAdmin` and on `demo.filerise.net`
|
||||
- **API Docs** calls `openApiModal()`
|
||||
- **Logout** calls `triggerLogout()`
|
||||
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
|
||||
@@ -238,7 +461,7 @@
|
||||
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
|
||||
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
|
||||
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
|
||||
- Inserted a **proxy-only auto-login** block *before* the usual session/auth checks:
|
||||
- Inserted a **proxy-only auto-login** block before the usual session/auth checks:
|
||||
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
|
||||
- Relax filename validation regex to allow broader Unicode and special chars
|
||||
|
||||
@@ -280,7 +503,7 @@
|
||||
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
|
||||
- No other core fetch/token logic changed; all existing flows remain intact.
|
||||
|
||||
### Security
|
||||
### Security old
|
||||
|
||||
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||
|
||||
@@ -309,7 +532,7 @@
|
||||
|
||||
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
|
||||
|
||||
### `main.js`
|
||||
**`main.js`**
|
||||
|
||||
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
|
||||
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
|
||||
|
||||
253
README.md
253
README.md
@@ -1,5 +1,14 @@
|
||||
# FileRise
|
||||
|
||||
[](https://github.com/error311/FileRise)
|
||||
[](https://hub.docker.com/r/error311/filerise-docker)
|
||||
[](https://github.com/error311/FileRise/actions/workflows/ci.yml)
|
||||
[](https://demo.filerise.net) **demo / demo**
|
||||
[](https://github.com/error311/FileRise/releases)
|
||||
[](LICENSE)
|
||||
|
||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||
|
||||
@@ -14,76 +23,102 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
|
||||
|
||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. No more failed transfers – FileRise will pick up where it left off if your connection drops.
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops.
|
||||
|
||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can even download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
||||
|
||||
- 🗃️ **Folder Sharing & File Sharing:** Easily share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls, and file sizes are displayed in MB for clarity. Share files with others using one-time or expiring public links (with password protection if desired) – convenient for sending individual files without exposing the whole app.
|
||||
- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls; file sizes are displayed in MB for clarity. Share individual files with one-time or expiring links (optional password protection).
|
||||
|
||||
- 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it head‑less from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` – see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl%C2%A0(WebDAV)) quick‑start for examples. Folder‑Only users are restricted to their personal directory, while admins and unrestricted users have full access.
|
||||
- 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it head-less from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` – see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl-(WebDAV)) quick-starts. Folder-Only users are restricted to their personal directory; admins and unrestricted users have full access.
|
||||
|
||||
- 📚 **API Documentation:** Fully auto‑generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
||||
|
||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal – no need to download just to see them. Edit text/code files right in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers. Great for config files or notes – tweak and save changes without leaving FileRise.
|
||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal. Edit text/code files in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers.
|
||||
|
||||
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using our indexed real-time search. Easily switch to Advanced Search mode to enable fuzzy matching not only across file names, tags, and uploader fields but also within the content of text files—helping you find that “important” document even if you make a typo or need to search deep within the file.
|
||||
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using indexed real-time search. **Advanced Search** adds fuzzy matching across file names, tags, uploader fields, and within text file contents.
|
||||
|
||||
- 🔒 **User Authentication & User Permissions:** Secure your portal with username/password login. Supports multiple users – create user accounts (admin UI provided) for family or team members. User permissions such as User “Folder Only” feature assigns each user a dedicated folder within the root directory, named after their username, restricting them from viewing or modifying other directories. User Read Only and Disable Upload are additional permissions. FileRise also integrates with Single Sign-On (OIDC) providers (e.g., OAuth2/OIDC for Google/Authentik/Keycloak) and offers optional TOTP two-factor auth for extra security.
|
||||
- 🔒 **User Authentication & Permissions:** Username/password login with multi-user support (admin UI). Current permissions: **Folder-only**, **Read-only**, **Disable upload**. SSO via OIDC providers (Google/Authentik/Keycloak) and optional TOTP 2FA.
|
||||
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** FileRise is mobile-friendly out of the box – manage files from your phone or tablet with a responsive layout. Choose between Dark mode or Light theme, or let it follow your system preference. The interface remembers your preferences (layout, items per page, last visited folder, etc.) for a personalized experience each time.
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.).
|
||||
|
||||
- 🌐 **Internationalization & Localization:** FileRise supports multiple languages via an integrated i18n system. Users can switch languages through a user panel dropdown, and their choice is saved in local storage for a consistent experience across sessions. Currently available in English, Spanish, French & German—please report any translation issues you encounter.
|
||||
- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome.
|
||||
|
||||
- 🗑️ **Trash & File Recovery:** Mistakenly deleted files? No worries – deleted items go to the Trash instead of immediate removal. Admins can restore files from Trash or empty it to free space. FileRise auto-purges old trash entries (default 3 days) to keep your storage tidy.
|
||||
- 🗑️ **Trash & File Recovery:** Deleted items go to Trash first; admins can restore or empty. Old trash entries auto-purge (default 3 days).
|
||||
|
||||
- ⚙️ **Lightweight & Self‑Contained:** FileRise runs on PHP 8.1+ with no external database required – data is stored in files (users, metadata) for simplicity. It’s a single‑folder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our pre‑built image for a hassle‑free setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
|
||||
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP **8.3+** with no external database. Single-folder install or Docker image. Low footprint; scales to thousands of files with pagination and sorting.
|
||||
|
||||
(For a full list of features and detailed changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [changelog](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or the [releases](https://github.com/error311/FileRise/releases) pages.)
|
||||
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
[](https://demo.filerise.net)
|
||||
**Demo credentials:** `demo` / `demo`
|
||||
|
||||
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). *The demo is read-only for security*. Explore the interface, switch themes, preview files, and see FileRise in action!
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
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.
|
||||
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server.
|
||||
|
||||
### 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**. Set to **false** later for faster restarts.
|
||||
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
|
||||
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
|
||||
- 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,75 +126,113 @@ 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.
|
||||
Access at `http://localhost:8080` (or your server’s IP).
|
||||
The example sets a custom `PERSISTENT_TOKENS_KEY`—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.
|
||||
|
||||
### 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).
|
||||
|
||||
``` 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).
|
||||
|
||||
- **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.)
|
||||
|
||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
||||
|
||||
``` bash
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate 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.
|
||||
|
||||
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
|
||||
|
||||
- `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.
|
||||
|
||||
- **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.
|
||||
|
||||
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!
|
||||
**First-time Setup**
|
||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||
|
||||
---
|
||||
|
||||
## Quick‑start: Mount via WebDAV
|
||||
### 2) Manual Installation (PHP/Apache)
|
||||
|
||||
Once FileRise is running, you must enable WebDAV in admin panel to access it.
|
||||
If you prefer a traditional web server (LAMP stack or similar):
|
||||
|
||||
**Requirements**
|
||||
|
||||
- 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 in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||
|
||||
**Composer (if applicable)**
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
**Folders & Permissions**
|
||||
|
||||
```bash
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # use your web user
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
- `uploads/`: actual files
|
||||
- `users/`: credentials & token storage
|
||||
- `metadata/`: file metadata (tags, share links, etc.)
|
||||
|
||||
**Configuration**
|
||||
|
||||
Edit `config.php`:
|
||||
|
||||
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
|
||||
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
|
||||
|
||||
**Share link base URL**
|
||||
|
||||
- Set **`SHARE_URL`** via web-server env vars (preferred),
|
||||
**or** keep using `BASE_URL` in `config.php` as a fallback.
|
||||
- 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 basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||
|
||||
Browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||
|
||||
---
|
||||
|
||||
## Unraid
|
||||
|
||||
- Install from **Community Apps** → search **FileRise**.
|
||||
- Default **bridge**: access at `http://SERVER_IP:8080/`.
|
||||
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
|
||||
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
|
||||
|
||||
---
|
||||
|
||||
## Quick-start: Mount via WebDAV
|
||||
|
||||
Once FileRise is running, enable WebDAV in the admin panel.
|
||||
|
||||
```bash
|
||||
# Linux (GVFS/GIO)
|
||||
gio mount dav://demo@your-host/webdav.php/
|
||||
|
||||
# macOS (Finder → Go → Connect to Server…)
|
||||
dav://demo@your-host/webdav.php/
|
||||
|
||||
https://your-host/webdav.php/
|
||||
```
|
||||
|
||||
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
|
||||
|
||||
### Windows (File Explorer)
|
||||
|
||||
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
||||
@@ -170,8 +243,8 @@ dav://demo@your-host/webdav.php/
|
||||
https://your-host/webdav.php/
|
||||
```
|
||||
|
||||
- Check **Connect using different credentials**, and enter your FileRise username and password.
|
||||
- Click **Finish**. The drive will now appear under **This PC**.
|
||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||
- Click **Finish**.
|
||||
|
||||
> **Important:**
|
||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
||||
@@ -186,33 +259,33 @@ dav://demo@your-host/webdav.php/
|
||||
>
|
||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
||||
> 4. Set its value to `2`.
|
||||
> 5. Restart the **WebClient** service or reboot your computer.
|
||||
> 5. Restart the **WebClient** service or reboot.
|
||||
|
||||
📖 For a full guide (including SSL setup, HTTP workaround, and troubleshooting), see the [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV).
|
||||
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
- **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are all set high enough. For extremely large files, you might also need to increase max_execution_time in PHP or rely on the resumable upload feature in smaller chunks.
|
||||
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
||||
|
||||
- **How to enable HTTPS?** FileRise itself doesn’t handle TLS. Run it behind a reverse proxy like Nginx, Caddy, or Apache with SSL, or use Docker with a companion like nginx-proxy or Caddy. Set `SECURE="true"` env var in Docker so FileRise knows to generate https links.
|
||||
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
||||
|
||||
- **Changing Admin or resetting password:** Admin can change any user’s password via the UI (User Management section). If you lose admin access, you can edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and then restart the app to trigger the setup flow again.
|
||||
- **Changing Admin or resetting password:** Admin can change any user’s password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
|
||||
|
||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set for `UPLOAD_DIR`). Within it, files are organized in the folder structure you see in the app. Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata`.json and trash metadata in `metadata/trash.json`, etc. Regular backups of these folders is recommended if the data is important.
|
||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
|
||||
|
||||
- **Updating FileRise:** If using Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace the files (preserve your `config.php` and the uploads/users/metadata folders). Clear your browser cache if you have issues after an update (in case CSS/JS changed).
|
||||
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
|
||||
|
||||
For more Q&A or to ask for help, please check the Discussions or open an issue.
|
||||
For more Q&A or to ask for help, open a Discussion or Issue.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you have ideas for new features or have found a bug, feel free to open an issue. Check out the [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. You can also join the conversation in GitHub Discussions or on Reddit (see links below) to share feedback and suggestions.
|
||||
|
||||
Areas where you can help: translations, bug fixes, UI improvements, or building integration with other services. If you like FileRise, giving the project a ⭐ star ⭐ on GitHub is also a much-appreciated contribution!
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Areas to help: translations, bug fixes, UI polish, integrations.
|
||||
If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
||||
|
||||
---
|
||||
|
||||
@@ -220,7 +293,9 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
||||
|
||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
||||
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
||||
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.
|
||||
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
|
||||
|
||||
[](https://star-history.com/#error311/FileRise&Date)
|
||||
|
||||
---
|
||||
|
||||
@@ -253,4 +328,4 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
||||
|
||||
## License
|
||||
|
||||
This project is open-source under the MIT License. That means you’re free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!
|
||||
MIT License – see [LICENSE](LICENSE).
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
FileRise is actively maintained. Only supported versions will receive security updates. For details on which versions are currently supported, please see the [Release Notes](https://github.com/error311/FileRise/releases).
|
||||
We provide security fixes for the latest minor release line.
|
||||
|
||||
| Version | Supported |
|
||||
|------------|-----------|
|
||||
| v1.4.x | ✅ |
|
||||
| < v1.4.0 | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
||||
define('TIMEZONE', 'America/New_York');
|
||||
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
||||
define('TOTAL_UPLOAD_SIZE','5G');
|
||||
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
|
||||
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
|
||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
@@ -36,6 +36,13 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
|
||||
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
|
||||
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||
|
||||
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
{
|
||||
@@ -196,13 +203,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);
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
filerise:
|
||||
# Use the published image (does NOT build in CI by default)
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
restart: unless-stopped
|
||||
|
||||
# If someone wants to build locally instead, they can uncomment:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
|
||||
ports:
|
||||
- "${HOST_HTTP_PORT:-8080}:80"
|
||||
# Uncomment if you really terminate TLS inside the container:
|
||||
# - "${HOST_HTTPS_PORT:-8443}:443"
|
||||
|
||||
environment:
|
||||
TIMEZONE: "${TIMEZONE:-UTC}"
|
||||
DATE_TIME_FORMAT: "${DATE_TIME_FORMAT:-m/d/y h:iA}"
|
||||
TOTAL_UPLOAD_SIZE: "${TOTAL_UPLOAD_SIZE:-5G}"
|
||||
SECURE: "${SECURE:-false}"
|
||||
PERSISTENT_TOKENS_KEY: "${PERSISTENT_TOKENS_KEY:-please_change_this_@@}"
|
||||
PUID: "${PUID:-1000}"
|
||||
PGID: "${PGID:-1000}"
|
||||
CHOWN_ON_START: "${CHOWN_ON_START:-true}"
|
||||
SCAN_ON_START: "${SCAN_ON_START:-true}"
|
||||
SHARE_URL: "${SHARE_URL:-}"
|
||||
|
||||
volumes:
|
||||
- ./data/uploads:/var/www/uploads
|
||||
- ./data/users:/var/www/users
|
||||
- ./data/metadata:/var/www/metadata
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
@@ -3,9 +3,15 @@ import { loadAdminConfigFunc } from './auth.js';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
|
||||
const version = "v1.3.8";
|
||||
const version = "v1.4.0";
|
||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||
|
||||
// Translate with fallback: if t(key) just echos the key, use a readable string.
|
||||
const tf = (key, fallback) => {
|
||||
const v = t(key);
|
||||
return (v && v !== key) ? v : fallback;
|
||||
};
|
||||
|
||||
// ————— Inject updated styles —————
|
||||
(function () {
|
||||
if (document.getElementById('adminPanelStyles')) return;
|
||||
@@ -493,21 +499,21 @@ export function openAdminPanel() {
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const dFL = document.getElementById("disableFormLogin").checked;
|
||||
const dBA = document.getElementById("disableBasicAuth").checked;
|
||||
const dOIDC = document.getElementById("disableOIDCLogin").checked;
|
||||
const aBypass= document.getElementById("authBypass").checked;
|
||||
const aHeader= document.getElementById("authHeaderName").value.trim() || "X-Remote-User";
|
||||
const eWD = document.getElementById("enableWebDAV").checked;
|
||||
const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0;
|
||||
const nHT = document.getElementById("headerTitle").value.trim();
|
||||
const nOIDC = {
|
||||
const dFL = document.getElementById("disableFormLogin").checked;
|
||||
const dBA = document.getElementById("disableBasicAuth").checked;
|
||||
const dOIDC = document.getElementById("disableOIDCLogin").checked;
|
||||
const aBypass = document.getElementById("authBypass").checked;
|
||||
const aHeader = document.getElementById("authHeaderName").value.trim() || "X-Remote-User";
|
||||
const eWD = document.getElementById("enableWebDAV").checked;
|
||||
const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0;
|
||||
const nHT = document.getElementById("headerTitle").value.trim();
|
||||
const nOIDC = {
|
||||
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
||||
clientId: document.getElementById("oidcClientId").value.trim(),
|
||||
clientSecret:document.getElementById("oidcClientSecret").value.trim(),
|
||||
clientId: document.getElementById("oidcClientId").value.trim(),
|
||||
clientSecret: document.getElementById("oidcClientSecret").value.trim(),
|
||||
redirectUri: document.getElementById("oidcRedirectUri").value.trim()
|
||||
};
|
||||
const gURL = document.getElementById("globalOtpauthUrl").value.trim();
|
||||
const gURL = document.getElementById("globalOtpauthUrl").value.trim();
|
||||
|
||||
if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
|
||||
showToast(t("at_least_one_login_method"));
|
||||
@@ -521,25 +527,25 @@ function handleSave() {
|
||||
disableFormLogin: dFL,
|
||||
disableBasicAuth: dBA,
|
||||
disableOIDCLogin: dOIDC,
|
||||
authBypass: aBypass,
|
||||
authHeaderName: aHeader
|
||||
authBypass: aBypass,
|
||||
authHeaderName: aHeader
|
||||
},
|
||||
enableWebDAV: eWD,
|
||||
sharedMaxUploadSize: sMax,
|
||||
globalOtpauthUrl: gURL
|
||||
enableWebDAV: eWD,
|
||||
sharedMaxUploadSize: sMax,
|
||||
globalOtpauthUrl: gURL
|
||||
}, {
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
})
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
captureInitialAdminConfig();
|
||||
closeAdminPanel();
|
||||
loadAdminConfigFunc();
|
||||
} else {
|
||||
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
|
||||
}
|
||||
}).catch(() => {/*noop*/});
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
captureInitialAdminConfig();
|
||||
closeAdminPanel();
|
||||
loadAdminConfigFunc();
|
||||
} else {
|
||||
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
|
||||
}
|
||||
}).catch(() => {/*noop*/ });
|
||||
}
|
||||
|
||||
export async function closeAdminPanel() {
|
||||
@@ -605,15 +611,16 @@ export function openUserPermissionsModal() {
|
||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||
const permissionsData = [];
|
||||
rows.forEach(row => {
|
||||
const username = row.getAttribute("data-username");
|
||||
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
|
||||
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
|
||||
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
|
||||
const g = k => row.querySelector(`input[data-permission='${k}']`)?.checked ?? false;
|
||||
permissionsData.push({
|
||||
username,
|
||||
folderOnly: folderOnlyCheckbox.checked,
|
||||
readOnly: readOnlyCheckbox.checked,
|
||||
disableUpload: disableUploadCheckbox.checked
|
||||
username: row.getAttribute("data-username"),
|
||||
folderOnly: g("folderOnly"),
|
||||
readOnly: g("readOnly"),
|
||||
disableUpload: g("disableUpload"),
|
||||
bypassOwnership: g("bypassOwnership"),
|
||||
canShare: g("canShare"),
|
||||
canZip: g("canZip"),
|
||||
viewOwnOnly: g("viewOwnOnly"),
|
||||
});
|
||||
});
|
||||
// Send the permissionsData to the server.
|
||||
@@ -664,38 +671,79 @@ function loadUserPermissionsList() {
|
||||
folderOnly: false,
|
||||
readOnly: false,
|
||||
disableUpload: false,
|
||||
bypassOwnership: false,
|
||||
canShare: false,
|
||||
canZip: false,
|
||||
viewOwnOnly: false,
|
||||
};
|
||||
|
||||
// Normalize the username key to match server storage (e.g., lowercase)
|
||||
const usernameKey = user.username.toLowerCase();
|
||||
|
||||
|
||||
const toBool = v => v === true || v === 1 || v === "1";
|
||||
const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData))
|
||||
? permissionsData[usernameKey]
|
||||
: defaultPerm;
|
||||
|
||||
// Create a row for the user.
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("user-permission-row");
|
||||
row.setAttribute("data-username", user.username);
|
||||
row.style.padding = "10px 0";
|
||||
row.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
|
||||
${t("user_folder_only")}
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
|
||||
${t("read_only")}
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
|
||||
${t("disable_upload")}
|
||||
</label>
|
||||
</div>
|
||||
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
|
||||
`;
|
||||
|
||||
// Create a row for the user (collapsed by default)
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("user-permission-row");
|
||||
row.setAttribute("data-username", user.username);
|
||||
row.style.padding = "6px 0";
|
||||
|
||||
// helper for checkbox checked state
|
||||
const checked = key => (userPerm && userPerm[key]) ? "checked" : "";
|
||||
|
||||
// header + caret
|
||||
row.innerHTML = `
|
||||
<div class="user-perm-header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded="false"
|
||||
style="display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 6px;border-radius:6px;cursor:pointer;
|
||||
background:var(--perm-header-bg, rgba(0,0,0,0.04));">
|
||||
<span style="font-weight:600;">${user.username}</span>
|
||||
<i class="material-icons perm-caret" style="transition:transform .2s; transform:rotate(-90deg);">expand_more</i>
|
||||
</div>
|
||||
|
||||
<div class="user-perm-details"
|
||||
style="display:none;margin:8px 4px 2px 10px;
|
||||
display:none;gap:8px;
|
||||
grid-template-columns: 1fr 1fr;">
|
||||
<label><input type="checkbox" data-permission="folderOnly" ${checked("folderOnly")}/> ${t("user_folder_only")}</label>
|
||||
<label><input type="checkbox" data-permission="readOnly" ${checked("readOnly")}/> ${t("read_only")}</label>
|
||||
<label><input type="checkbox" data-permission="disableUpload" ${checked("disableUpload")}/> ${t("disable_upload")}</label>
|
||||
|
||||
<label><input type="checkbox" data-permission="bypassOwnership" ${checked("bypassOwnership")}/> Bypass ownership</label>
|
||||
<label><input type="checkbox" data-permission="canShare" ${checked("canShare")}/> Can share</label>
|
||||
<label><input type="checkbox" data-permission="canZip" ${checked("canZip")}/> Can zip</label>
|
||||
<label><input type="checkbox" data-permission="viewOwnOnly" ${checked("viewOwnOnly")}/> View own files only</label>
|
||||
</div>
|
||||
|
||||
<hr style="margin:8px 0 4px;border:0;border-bottom:1px solid #ccc;">
|
||||
`;
|
||||
|
||||
// toggle open/closed on click + Enter/Space
|
||||
const header = row.querySelector(".user-perm-header");
|
||||
const details = row.querySelector(".user-perm-details");
|
||||
const caret = row.querySelector(".perm-caret");
|
||||
|
||||
function toggleOpen() {
|
||||
const willShow = details.style.display === "none";
|
||||
details.style.display = willShow ? "grid" : "none";
|
||||
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
||||
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
||||
}
|
||||
|
||||
header.addEventListener("click", toggleOpen);
|
||||
header.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
||||
});
|
||||
|
||||
listContainer.appendChild(row);
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -49,6 +55,18 @@ window.advancedSearchEnabled = false;
|
||||
* --- Helper Functions ---
|
||||
*/
|
||||
|
||||
// Safely parse JSON; if server returned HTML/text, throw it as a readable error.
|
||||
async function safeJson(res) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Common cases: PHP notice/HTML, "Access forbidden.", etc.
|
||||
const msg = (text || '').toString().trim();
|
||||
throw new Error(msg || `Unexpected ${res.status} ${res.statusText} from ${res.url || 'request'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||||
*/
|
||||
@@ -202,51 +220,48 @@ 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()}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const foldersPromise = fetch(
|
||||
`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
|
||||
// ----- 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 }));
|
||||
const data = await safeJson(filesRes);
|
||||
if (data.error) {
|
||||
throw new Error(typeof data.error === 'string' ? data.error : 'Server returned an error.');
|
||||
}
|
||||
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||
|
||||
// 3) clear loader
|
||||
// If another loadFileList ran after this one, bail before touching the DOM
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
|
||||
// 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 +270,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 +286,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 +368,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 +393,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 +410,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 safeJson(foldersRes).catch(() => []); // don't block file render on folder strip issues
|
||||
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 +534,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 +1194,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
||||
import { initUpload } from './upload.js';
|
||||
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
|
||||
const _originalFetch = window.fetch;
|
||||
window.fetch = fetchWithCsrf;
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
||||
@@ -14,14 +12,60 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
|
||||
import { editFile, saveFile } from './fileEditor.js';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||
|
||||
/* =========================
|
||||
CSRF HOTFIX UTILITIES
|
||||
========================= */
|
||||
const _nativeFetch = window.fetch; // keep the real fetch
|
||||
|
||||
function setCsrfToken(token) {
|
||||
if (!token) return;
|
||||
window.csrfToken = token;
|
||||
localStorage.setItem('csrf', token);
|
||||
|
||||
// meta tag for easy access in other places
|
||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'csrf-token';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = token;
|
||||
}
|
||||
function getCsrfToken() {
|
||||
return window.csrfToken || localStorage.getItem('csrf') || '';
|
||||
}
|
||||
|
||||
// Seed CSRF from storage ASAP (before any requests)
|
||||
setCsrfToken(getCsrfToken());
|
||||
|
||||
// Wrap the existing fetchWithCsrf so we also capture rotated tokens from headers.
|
||||
async function fetchWithCsrfAndRefresh(input, init = {}) {
|
||||
const res = await fetchWithCsrf(input, init);
|
||||
try {
|
||||
const rotated = res.headers?.get('X-CSRF-Token');
|
||||
if (rotated) setCsrfToken(rotated);
|
||||
} catch { /* ignore */ }
|
||||
return res;
|
||||
}
|
||||
|
||||
// Replace global fetch with the wrapped version so *all* callers benefit.
|
||||
window.fetch = fetchWithCsrfAndRefresh;
|
||||
|
||||
/* =========================
|
||||
APP INIT
|
||||
========================= */
|
||||
|
||||
export function initializeApp() {
|
||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||
|
||||
window.currentFolder = "root";
|
||||
initTagSearch();
|
||||
loadFileList(window.currentFolder);
|
||||
const stored = localStorage.getItem('showFoldersInList');
|
||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||
|
||||
initTagSearch();
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const fileListArea = document.getElementById('fileListContainer');
|
||||
const uploadArea = document.getElementById('uploadDropArea');
|
||||
if (fileListArea && uploadArea) {
|
||||
@@ -35,7 +79,6 @@ export function initializeApp() {
|
||||
fileListArea.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
// re-dispatch the same drop into the real upload card
|
||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||
dataTransfer: e.dataTransfer,
|
||||
bubbles: true,
|
||||
@@ -63,27 +106,36 @@ export function initializeApp() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap/refresh CSRF from the server.
|
||||
* Uses the *native* fetch to avoid any wrapper loops and to work even if we don't
|
||||
* yet have a token. Also accepts a rotated token from the response header.
|
||||
*/
|
||||
export function loadCsrfToken() {
|
||||
return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(({ csrf_token, share_url }) => {
|
||||
window.csrfToken = csrf_token;
|
||||
return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
|
||||
.then(async res => {
|
||||
// header-based rotation
|
||||
const hdr = res.headers.get('X-CSRF-Token');
|
||||
if (hdr) setCsrfToken(hdr);
|
||||
|
||||
// update CSRF meta
|
||||
let meta = document.querySelector('meta[name="csrf-token"]') ||
|
||||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
|
||||
meta.content = csrf_token;
|
||||
// body (if provided)
|
||||
let body = {};
|
||||
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
|
||||
|
||||
// force share_url to match wherever we're browsing
|
||||
const token = body.csrf_token || getCsrfToken();
|
||||
setCsrfToken(token);
|
||||
|
||||
// share-url meta should reflect the actual origin
|
||||
const actualShare = window.location.origin;
|
||||
let shareMeta = document.querySelector('meta[name="share-url"]') ||
|
||||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
|
||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||
if (!shareMeta) {
|
||||
shareMeta = document.createElement('meta');
|
||||
shareMeta.name = 'share-url';
|
||||
document.head.appendChild(shareMeta);
|
||||
}
|
||||
shareMeta.content = actualShare;
|
||||
|
||||
return { csrf_token, share_url: actualShare };
|
||||
return { csrf_token: token, share_url: actualShare };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,16 +147,15 @@ if (params.get('logout') === '1') {
|
||||
}
|
||||
|
||||
export function triggerLogout() {
|
||||
fetch("/api/auth/logout.php", {
|
||||
_nativeFetch("/api/auth/logout.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": window.csrfToken }
|
||||
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||
})
|
||||
.then(() => window.location.reload(true))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
|
||||
// Expose functions for inline handlers.
|
||||
window.sendRequest = sendRequest;
|
||||
window.toggleVisibility = toggleVisibility;
|
||||
@@ -119,105 +170,79 @@ window.openDownloadModal = openDownloadModal;
|
||||
window.currentFolder = "root";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadAdminConfigFunc();
|
||||
|
||||
loadAdminConfigFunc(); // Then fetch the latest config and update.
|
||||
// Retrieve the saved language from localStorage; default to "en"
|
||||
// i18n
|
||||
const savedLanguage = localStorage.getItem("language") || "en";
|
||||
// Set the locale based on the saved language
|
||||
setLocale(savedLanguage);
|
||||
// Apply the translations to update the UI
|
||||
applyTranslations();
|
||||
// First, load the CSRF token (with retry).
|
||||
loadCsrfToken().then(() => {
|
||||
// Once CSRF token is loaded, initialize authentication.
|
||||
initAuth();
|
||||
|
||||
// Continue with initializations that rely on a valid CSRF token:
|
||||
checkAuthentication().then(authenticated => {
|
||||
if (authenticated) {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.remove();
|
||||
initializeApp();
|
||||
}
|
||||
});
|
||||
// 1) Get/refresh CSRF first
|
||||
loadCsrfToken()
|
||||
.then(() => {
|
||||
// 2) Auth boot
|
||||
initAuth();
|
||||
|
||||
// Other DOM initialization that can happen after CSRF is ready.
|
||||
const newPasswordInput = document.getElementById("newPassword");
|
||||
if (newPasswordInput) {
|
||||
newPasswordInput.addEventListener("input", function () {
|
||||
console.log("newPassword input event:", this.value);
|
||||
// 3) If authenticated, start app
|
||||
checkAuthentication().then(authenticated => {
|
||||
if (authenticated) {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.remove();
|
||||
initializeApp();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("newPassword input not found!");
|
||||
}
|
||||
|
||||
// --- Dark Mode Persistence ---
|
||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||
const darkModeIcon = document.getElementById("darkModeIcon");
|
||||
// --- Dark Mode Persistence ---
|
||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||
const darkModeIcon = document.getElementById("darkModeIcon");
|
||||
|
||||
if (darkModeToggle && darkModeIcon) {
|
||||
// 1) Load stored preference (or null)
|
||||
let stored = localStorage.getItem("darkMode");
|
||||
const hasStored = stored !== null;
|
||||
if (darkModeToggle && darkModeIcon) {
|
||||
let stored = localStorage.getItem("darkMode");
|
||||
const hasStored = stored !== null;
|
||||
|
||||
// 2) Determine initial mode
|
||||
const isDark = hasStored
|
||||
? (stored === "true")
|
||||
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const isDark = hasStored
|
||||
? (stored === "true")
|
||||
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
document.body.classList.toggle("dark-mode", isDark);
|
||||
darkModeToggle.classList.toggle("active", isDark);
|
||||
document.body.classList.toggle("dark-mode", isDark);
|
||||
darkModeToggle.classList.toggle("active", isDark);
|
||||
|
||||
// 3) Helper to update icon & aria-label
|
||||
function updateIcon() {
|
||||
const dark = document.body.classList.contains("dark-mode");
|
||||
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
||||
darkModeToggle.setAttribute(
|
||||
"aria-label",
|
||||
dark ? t("light_mode") : t("dark_mode")
|
||||
);
|
||||
darkModeToggle.setAttribute(
|
||||
"title",
|
||||
dark
|
||||
? t("switch_to_light_mode")
|
||||
: t("switch_to_dark_mode")
|
||||
);
|
||||
}
|
||||
|
||||
updateIcon();
|
||||
|
||||
// 4) Click handler: always override and store preference
|
||||
darkModeToggle.addEventListener("click", () => {
|
||||
const nowDark = document.body.classList.toggle("dark-mode");
|
||||
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||
function updateIcon() {
|
||||
const dark = document.body.classList.contains("dark-mode");
|
||||
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
||||
darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode"));
|
||||
darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode"));
|
||||
}
|
||||
updateIcon();
|
||||
});
|
||||
|
||||
// 5) OS‐level change: only if no stored pref at load
|
||||
if (!hasStored && window.matchMedia) {
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", e => {
|
||||
darkModeToggle.addEventListener("click", () => {
|
||||
const nowDark = document.body.classList.toggle("dark-mode");
|
||||
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||
updateIcon();
|
||||
});
|
||||
|
||||
if (!hasStored && window.matchMedia) {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
|
||||
document.body.classList.toggle("dark-mode", e.matches);
|
||||
updateIcon();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- End Dark Mode Persistence ---
|
||||
// --- End Dark Mode Persistence ---
|
||||
|
||||
const message = sessionStorage.getItem("welcomeMessage");
|
||||
if (message) {
|
||||
showToast(message);
|
||||
sessionStorage.removeItem("welcomeMessage");
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||
});
|
||||
const message = sessionStorage.getItem("welcomeMessage");
|
||||
if (message) {
|
||||
showToast(message);
|
||||
sessionStorage.removeItem("welcomeMessage");
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||
});
|
||||
|
||||
// --- Auto-scroll During Drag ---
|
||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
||||
|
||||
const SCROLL_THRESHOLD = 50;
|
||||
const SCROLL_SPEED = 20;
|
||||
document.addEventListener("dragover", function (e) {
|
||||
if (e.clientY < SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, -SCROLL_SPEED);
|
||||
|
||||
@@ -669,6 +669,18 @@ function submitFiles(allFiles) {
|
||||
}
|
||||
allSucceeded = false;
|
||||
}
|
||||
if (file.isClipboard) {
|
||||
setTimeout(() => {
|
||||
window.selectedFiles = [];
|
||||
updateFileInfoCount();
|
||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||
if (progressContainer) progressContainer.innerHTML = "";
|
||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||
if (fileInfoContainer) {
|
||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ─── Only now count this chunk as finished ───────────────────
|
||||
finishedCount++;
|
||||
@@ -847,4 +859,39 @@ function initUpload() {
|
||||
}
|
||||
}
|
||||
|
||||
export { initUpload };
|
||||
export { initUpload };
|
||||
|
||||
// -------------------------
|
||||
// Clipboard Paste Handler (Mimics Drag-and-Drop)
|
||||
// -------------------------
|
||||
document.addEventListener('paste', function handlePasteUpload(e) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const ext = file.name.split('.').pop() || 'png';
|
||||
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
|
||||
renamedFile.isClipboard = true;
|
||||
|
||||
Object.defineProperty(renamedFile, 'customRelativePath', {
|
||||
value: renamedFile.name,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
files.push(renamedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
showToast('Pasted file added to upload list.', 'success');
|
||||
}
|
||||
});
|
||||
143
scripts/scan_uploads.php
Normal file
143
scripts/scan_uploads.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* scan_uploads.php
|
||||
* Rebuild/repair per-folder metadata used by FileRise models.
|
||||
* - Uses UPLOAD_DIR / META_DIR / DATE_TIME_FORMAT from config.php
|
||||
* - Per-folder metadata naming matches FileModel/FolderModel:
|
||||
* "root" -> root_metadata.json
|
||||
* "<sub/dir>" -> str_replace(['/', '\\', ' '], '-', '<sub/dir>') . '_metadata.json'
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// ---------- helpers that mirror model behavior ----------
|
||||
|
||||
/** Compute the metadata JSON path for a folder key (e.g., "root", "invoices/2025"). */
|
||||
function folder_metadata_path(string $folderKey): string {
|
||||
if (strtolower(trim($folderKey)) === 'root' || trim($folderKey) === '') {
|
||||
return rtrim(META_DIR, '/\\') . '/root_metadata.json';
|
||||
}
|
||||
$safe = str_replace(['/', '\\', ' '], '-', trim($folderKey));
|
||||
return rtrim(META_DIR, '/\\') . '/' . $safe . '_metadata.json';
|
||||
}
|
||||
|
||||
/** Turn an absolute path under UPLOAD_DIR into a folder key (“root” or relative with slashes). */
|
||||
function to_folder_key(string $absPath): string {
|
||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (realpath($absPath) === realpath(rtrim(UPLOAD_DIR, '/\\'))) {
|
||||
return 'root';
|
||||
}
|
||||
$rel = ltrim(str_replace('\\', '/', substr($absPath, strlen($base))), '/');
|
||||
return $rel;
|
||||
}
|
||||
|
||||
/** List immediate files in a directory (no subdirs). */
|
||||
function list_files(string $dir): array {
|
||||
$out = [];
|
||||
$entries = @scandir($dir);
|
||||
if ($entries === false) return $out;
|
||||
foreach ($entries as $name) {
|
||||
if ($name === '.' || $name === '..') continue;
|
||||
$p = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
if (is_file($p)) $out[] = $name;
|
||||
}
|
||||
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Recursively list subfolders (relative folder keys), skipping trash/. */
|
||||
function list_all_folders(string $root): array {
|
||||
$root = rtrim($root, '/\\');
|
||||
$folders = ['root'];
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($it as $path => $info) {
|
||||
if ($info->isDir()) {
|
||||
// relative key like "foo/bar"
|
||||
$rel = ltrim(str_replace(['\\'], '/', substr($path, strlen($root) + 1)), '/');
|
||||
if ($rel === '') continue;
|
||||
// skip trash and profile_pics subtrees
|
||||
if ($rel === 'trash' || strpos($rel, 'trash/') === 0) continue;
|
||||
if ($rel === 'profile_pics' || strpos($rel, 'profile_pics/') === 0) continue;
|
||||
// obey the app’s folder-name regex to stay consistent
|
||||
if (preg_match(REGEX_FOLDER_NAME, basename($rel))) {
|
||||
$folders[] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
// de-dup and sort
|
||||
$folders = array_values(array_unique($folders));
|
||||
sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $folders;
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
|
||||
$uploads = rtrim(UPLOAD_DIR, '/\\');
|
||||
$metaDir = rtrim(META_DIR, '/\\');
|
||||
|
||||
// Ensure metadata dir exists
|
||||
if (!is_dir($metaDir)) {
|
||||
@mkdir($metaDir, 0775, true);
|
||||
}
|
||||
|
||||
$now = date(DATE_TIME_FORMAT);
|
||||
$folders = list_all_folders($uploads);
|
||||
|
||||
$totalCreated = 0;
|
||||
$totalPruned = 0;
|
||||
|
||||
foreach ($folders as $folderKey) {
|
||||
$absFolder = ($folderKey === 'root')
|
||||
? $uploads
|
||||
: $uploads . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderKey);
|
||||
|
||||
if (!is_dir($absFolder)) continue;
|
||||
|
||||
$files = list_files($absFolder);
|
||||
|
||||
$metaPath = folder_metadata_path($folderKey);
|
||||
$metadata = [];
|
||||
if (is_file($metaPath)) {
|
||||
$decoded = json_decode(@file_get_contents($metaPath), true);
|
||||
if (is_array($decoded)) $metadata = $decoded;
|
||||
}
|
||||
|
||||
// Build a quick lookup of existing entries
|
||||
$existing = array_keys($metadata);
|
||||
|
||||
// ADD missing files
|
||||
foreach ($files as $name) {
|
||||
// Keep same filename validation used in FileModel
|
||||
if (!preg_match(REGEX_FILE_NAME, $name)) continue;
|
||||
|
||||
if (!isset($metadata[$name])) {
|
||||
$metadata[$name] = [
|
||||
'uploaded' => $now,
|
||||
'modified' => $now,
|
||||
'uploader' => 'Imported'
|
||||
];
|
||||
$totalCreated++;
|
||||
echo "Indexed: " . ($folderKey === 'root' ? '' : $folderKey . '/') . $name . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
// PRUNE stale metadata entries for files that no longer exist
|
||||
foreach ($existing as $name) {
|
||||
if (!in_array($name, $files, true)) {
|
||||
unset($metadata[$name]);
|
||||
$totalPruned++;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent dir exists and write metadata
|
||||
@mkdir(dirname($metaPath), 0775, true);
|
||||
if (@file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||
fwrite(STDERR, "Failed to write metadata for folder: {$folderKey}\n");
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done. Created {$totalCreated} entr" . ($totalCreated === 1 ? "y" : "ies") .
|
||||
", pruned {$totalPruned}.\n";
|
||||
@@ -53,6 +53,17 @@ class AdminController
|
||||
public function getConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Require authenticated admin to read config (prevents information disclosure)
|
||||
if (
|
||||
empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
empty($_SESSION['isAdmin'])
|
||||
) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$config = AdminModel::getConfig();
|
||||
if (isset($config['error'])) {
|
||||
http_response_code(500);
|
||||
@@ -62,14 +73,14 @@ class AdminController
|
||||
|
||||
// Build a safe subset for the front-end
|
||||
$safe = [
|
||||
'header_title' => $config['header_title'],
|
||||
'loginOptions' => $config['loginOptions'],
|
||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'],
|
||||
'enableWebDAV' => $config['enableWebDAV'],
|
||||
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'],
|
||||
'header_title' => $config['header_title'] ?? '',
|
||||
'loginOptions' => $config['loginOptions'] ?? [],
|
||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||
'enableWebDAV' => $config['enableWebDAV'] ?? false,
|
||||
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0,
|
||||
'oidc' => [
|
||||
'providerUrl' => $config['oidc']['providerUrl'],
|
||||
'redirectUri' => $config['oidc']['redirectUri'],
|
||||
'providerUrl' => $config['oidc']['providerUrl'] ?? '',
|
||||
'redirectUri' => $config['oidc']['redirectUri'] ?? '',
|
||||
// clientSecret and clientId never exposed here
|
||||
],
|
||||
];
|
||||
@@ -137,106 +148,186 @@ class AdminController
|
||||
* @return void Outputs a JSON response indicating success or failure.
|
||||
*/
|
||||
public function updateConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// —– auth & CSRF checks —–
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
||||
) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid CSRF token.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// —– fetch payload —–
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid input.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// —– load existing on-disk config —–
|
||||
$existing = AdminModel::getConfig();
|
||||
|
||||
// —– start merge with existing as base —–
|
||||
$merged = $existing;
|
||||
|
||||
// header_title
|
||||
if (array_key_exists('header_title', $data)) {
|
||||
$merged['header_title'] = trim($data['header_title']);
|
||||
}
|
||||
|
||||
// loginOptions: inherit existing then override if provided
|
||||
$merged['loginOptions'] = $existing['loginOptions'] ?? [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin'=> false,
|
||||
'authBypass' => false,
|
||||
'authHeaderName' => 'X-Remote-User'
|
||||
];
|
||||
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
|
||||
if (isset($data['loginOptions'][$flag])) {
|
||||
$merged['loginOptions'][$flag] = filter_var(
|
||||
$data['loginOptions'][$flag],
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
// —– auth & CSRF checks —–
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
||||
) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
if (isset($data['loginOptions']['authHeaderName'])) {
|
||||
$hdr = trim($data['loginOptions']['authHeaderName']);
|
||||
if ($hdr !== '') {
|
||||
$merged['loginOptions']['authHeaderName'] = $hdr;
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$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.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// globalOtpauthUrl
|
||||
if (array_key_exists('globalOtpauthUrl', $data)) {
|
||||
$merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']);
|
||||
}
|
||||
// —– fetch payload —–
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid input.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// enableWebDAV
|
||||
if (array_key_exists('enableWebDAV', $data)) {
|
||||
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
// —– load existing on-disk config —–
|
||||
$existing = AdminModel::getConfig();
|
||||
if (isset($existing['error'])) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $existing['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// sharedMaxUploadSize
|
||||
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
||||
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
||||
if ($sms !== false) {
|
||||
// —– start merge with existing as base —–
|
||||
// Ensure minimal structure if the file was partially missing.
|
||||
$merged = $existing + [
|
||||
'header_title' => '',
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin' => true,
|
||||
'authBypass' => false,
|
||||
'authHeaderName' => 'X-Remote-User'
|
||||
],
|
||||
'globalOtpauthUrl' => '',
|
||||
'enableWebDAV' => false,
|
||||
'sharedMaxUploadSize' => 0,
|
||||
'oidc' => [
|
||||
'providerUrl' => '',
|
||||
'clientId' => '',
|
||||
'clientSecret'=> '',
|
||||
'redirectUri' => ''
|
||||
],
|
||||
];
|
||||
|
||||
// header_title (cap length and strip control chars)
|
||||
if (array_key_exists('header_title', $data)) {
|
||||
$title = trim((string)$data['header_title']);
|
||||
$title = preg_replace('/[\x00-\x1F\x7F]/', '', $title);
|
||||
if (mb_strlen($title) > 100) { // hard cap
|
||||
$title = mb_substr($title, 0, 100);
|
||||
}
|
||||
$merged['header_title'] = $title;
|
||||
}
|
||||
|
||||
// loginOptions: inherit existing then override if provided
|
||||
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
|
||||
if (isset($data['loginOptions'][$flag])) {
|
||||
$merged['loginOptions'][$flag] = filter_var(
|
||||
$data['loginOptions'][$flag],
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isset($data['loginOptions']['authHeaderName'])) {
|
||||
$hdr = trim((string)$data['loginOptions']['authHeaderName']);
|
||||
// very restrictive header-name pattern: letters, numbers, dashes
|
||||
if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
|
||||
$merged['loginOptions']['authHeaderName'] = $hdr;
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid authHeaderName.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// globalOtpauthUrl
|
||||
if (array_key_exists('globalOtpauthUrl', $data)) {
|
||||
$merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
|
||||
}
|
||||
|
||||
// enableWebDAV
|
||||
if (array_key_exists('enableWebDAV', $data)) {
|
||||
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
// sharedMaxUploadSize
|
||||
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
||||
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
||||
if ($sms === false || $sms < 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']);
|
||||
exit;
|
||||
}
|
||||
// Clamp to PHP limits to avoid confusing UX
|
||||
$maxPost = self::iniToBytes(ini_get('post_max_size'));
|
||||
$maxFile = self::iniToBytes(ini_get('upload_max_filesize'));
|
||||
$phpCap = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX);
|
||||
if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) {
|
||||
$sms = $phpCap;
|
||||
}
|
||||
$merged['sharedMaxUploadSize'] = $sms;
|
||||
}
|
||||
}
|
||||
|
||||
// oidc: only overwrite non-empty inputs
|
||||
$merged['oidc'] = $existing['oidc'] ?? [
|
||||
'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>''
|
||||
];
|
||||
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
|
||||
if (!empty($data['oidc'][$f])) {
|
||||
$val = trim($data['oidc'][$f]);
|
||||
if ($f === 'providerUrl' || $f === 'redirectUri') {
|
||||
$val = filter_var($val, FILTER_SANITIZE_URL);
|
||||
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
|
||||
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
|
||||
if (!empty($data['oidc'][$f])) {
|
||||
$val = trim((string)$data['oidc'][$f]);
|
||||
if ($f === 'providerUrl' || $f === 'redirectUri') {
|
||||
$val = filter_var($val, FILTER_SANITIZE_URL);
|
||||
}
|
||||
$merged['oidc'][$f] = $val;
|
||||
}
|
||||
$merged['oidc'][$f] = $val;
|
||||
}
|
||||
|
||||
// If OIDC login is enabled, ensure required fields are present and sane
|
||||
$oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true;
|
||||
if ($oidcEnabled) {
|
||||
$prov = $merged['oidc']['providerUrl'] ?? '';
|
||||
$rid = $merged['oidc']['redirectUri'] ?? '';
|
||||
$cid = $merged['oidc']['clientId'] ?? '';
|
||||
// clientSecret may be empty for some PKCE-only flows, but commonly needed for code flow.
|
||||
if ($prov === '' || $rid === '' || $cid === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.']);
|
||||
exit;
|
||||
}
|
||||
// Require https except for localhost development
|
||||
$httpsOk = function(string $url): bool {
|
||||
if ($url === '') return false;
|
||||
$parts = parse_url($url);
|
||||
if (!$parts || empty($parts['scheme'])) return false;
|
||||
if ($parts['scheme'] === 'https') return true;
|
||||
if ($parts['scheme'] === 'http' && (isset($parts['host']) && ($parts['host'] === 'localhost' || $parts['host'] === '127.0.0.1'))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!$httpsOk($prov) || !$httpsOk($rid)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'providerUrl and redirectUri must be https (or http on localhost)']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// —– persist merged config —–
|
||||
$result = AdminModel::updateConfig($merged);
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(500);
|
||||
}
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
// —– persist merged config —–
|
||||
$result = AdminModel::updateConfig($merged);
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(500);
|
||||
/** Convert php.ini shorthand like "128M" to bytes */
|
||||
private static function iniToBytes($val)
|
||||
{
|
||||
if ($val === false || $val === null || $val === '') return 0;
|
||||
$val = trim((string)$val);
|
||||
$last = strtolower($val[strlen($val)-1]);
|
||||
$num = (int)$val;
|
||||
switch ($last) {
|
||||
case 'g': $num *= 1024;
|
||||
case 'm': $num *= 1024;
|
||||
case 'k': $num *= 1024;
|
||||
}
|
||||
return $num;
|
||||
}
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -3,9 +3,163 @@
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FileModel.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||
|
||||
|
||||
class FileController
|
||||
{
|
||||
/* =========================
|
||||
* Permission helpers (fail-closed)
|
||||
* ========================= */
|
||||
private function isAdmin(array $perms): bool {
|
||||
// explicit flags in permissions blob
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
|
||||
// session-based flags commonly set at login
|
||||
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
|
||||
|
||||
// sometimes apps store role in session
|
||||
$role = $_SESSION['role'] ?? null;
|
||||
if ($role === 'admin' || $role === '1' || $role === 1) return true;
|
||||
|
||||
// definitive fallback: read users.txt role ("1" means admin)
|
||||
$u = $_SESSION['username'] ?? '';
|
||||
if ($u) {
|
||||
$roleStr = userModel::getUserRole($u);
|
||||
if ($roleStr === '1') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isFolderOnly(array $perms): bool {
|
||||
return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
}
|
||||
|
||||
private function getMetadataPath(string $folder): string {
|
||||
$folder = trim($folder);
|
||||
if ($folder === '' || strtolower($folder) === 'root') {
|
||||
return META_DIR . 'root_metadata.json';
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
private function loadFolderMetadata(string $folder): array {
|
||||
$meta = $this->getMetadataPath($folder);
|
||||
if (file_exists($meta)) {
|
||||
$data = json_decode(file_get_contents($meta), true);
|
||||
if (is_array($data)) return $data;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Always return an array for user permissions.
|
||||
private function loadPerms(string $username): array
|
||||
{
|
||||
try {
|
||||
if (function_exists('loadUserPermissions')) {
|
||||
$p = loadUserPermissions($username);
|
||||
return is_array($p) ? $p : [];
|
||||
}
|
||||
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
|
||||
$all = userModel::getUserPermissions();
|
||||
if (is_array($all)) {
|
||||
if (isset($all[$username])) return (array)$all[$username];
|
||||
$lk = strtolower($username);
|
||||
if (isset($all[$lk])) return (array)$all[$lk];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Enforce that (a) folder-only users act only in their subtree, and
|
||||
* (b) non-admins own all files in the provided list (metadata.uploader === $username).
|
||||
* Returns an error string on violation, or null if ok. */
|
||||
private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string {
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
// Folder-only users must stay in "<username>" subtree
|
||||
if ($this->isFolderOnly($userPermissions) && !$this->isAdmin($userPermissions)) {
|
||||
$folder = trim($folder);
|
||||
if ($folder !== '' && strtolower($folder) !== 'root') {
|
||||
if ($folder !== $username && strpos($folder, $username . '/') !== 0) {
|
||||
return "Forbidden: folder scope violation.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($ignoreOwnership) return null;
|
||||
|
||||
$metadata = $this->loadFolderMetadata($folder);
|
||||
foreach ($files as $f) {
|
||||
$name = basename((string)$f);
|
||||
if (!isset($metadata[$name]['uploader']) || strcasecmp($metadata[$name]['uploader'], $username) !== 0) {
|
||||
return "Forbidden: you are not the owner of '{$name}'.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string {
|
||||
if ($this->isAdmin($userPermissions)) return null;
|
||||
if (!$this->isFolderOnly($userPermissions)) return null;
|
||||
|
||||
$folder = trim($folder);
|
||||
if ($folder !== '' && strtolower($folder) !== 'root') {
|
||||
if ($folder !== $username && strpos($folder, $username . '/') !== 0) {
|
||||
return "Forbidden: folder scope violation.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- JSON/session/error helpers (non-breaking additions) ---
|
||||
private function _jsonStart(): void {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
// Turn notices/warnings into exceptions so we can return JSON instead of HTML
|
||||
set_error_handler(function ($severity, $message, $file, $line) {
|
||||
if (!(error_reporting() & $severity)) return; // respect @-silence
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
}
|
||||
|
||||
private function _jsonEnd(): void {
|
||||
restore_error_handler();
|
||||
}
|
||||
|
||||
private function _jsonOut(array $payload, int $status = 200): void {
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private function _checkCsrf(): bool {
|
||||
$headersArr = function_exists('getallheaders')
|
||||
? array_change_key_case(getallheaders(), CASE_LOWER)
|
||||
: [];
|
||||
$receivedToken = $headersArr['x-csrf-token'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
$this->_jsonOut(['error' => 'Invalid CSRF token'], 403);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function _requireAuth(): bool {
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
$this->_jsonOut(['error' => 'Unauthorized'], 401);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function _readJsonBody(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/copyFiles.php",
|
||||
@@ -73,8 +227,8 @@ class FileController
|
||||
|
||||
// Check user permissions (assuming loadUserPermissions() is available).
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (!empty($userPermissions['readOnly'])) {
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
|
||||
exit;
|
||||
}
|
||||
@@ -106,6 +260,12 @@ class FileController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Scope + ownership on source; scope on destination
|
||||
$violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
|
||||
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
|
||||
|
||||
// Delegate to the model.
|
||||
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
||||
echo json_encode($result);
|
||||
@@ -177,7 +337,7 @@ class FileController
|
||||
|
||||
// Load user's permissions.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
|
||||
exit;
|
||||
@@ -199,6 +359,10 @@ class FileController
|
||||
}
|
||||
$folder = trim($folder, "/\\ ");
|
||||
|
||||
// Scope + ownership
|
||||
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
|
||||
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
|
||||
|
||||
// Delegate to the FileModel.
|
||||
$result = FileModel::deleteFiles($folder, $data['files']);
|
||||
echo json_encode($result);
|
||||
@@ -271,8 +435,8 @@ class FileController
|
||||
|
||||
// Verify that the user is not read-only.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (!empty($userPermissions['readOnly'])) {
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to move files."]);
|
||||
exit;
|
||||
}
|
||||
@@ -303,6 +467,12 @@ class FileController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Scope + ownership on source; scope on destination
|
||||
$violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions);
|
||||
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
|
||||
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
|
||||
|
||||
// Delegate to the model.
|
||||
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']);
|
||||
echo json_encode($result);
|
||||
@@ -351,64 +521,63 @@ class FileController
|
||||
* @return void Outputs a JSON response.
|
||||
*/
|
||||
public function renameFile()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify user permissions.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to rename files."]);
|
||||
exit;
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
|
||||
$this->_jsonOut(["error" => "Read-only users are not allowed to rename files."], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get JSON input.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid input"]);
|
||||
exit;
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
|
||||
$this->_jsonOut(["error" => "Invalid input"], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$folder = trim($data['folder']) ?: 'root';
|
||||
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
$folder = trim((string)$data['folder']) ?: 'root';
|
||||
$oldName = basename(trim((string)$data['oldName']));
|
||||
$newName = basename(trim((string)$data['newName']));
|
||||
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
exit;
|
||||
$this->_jsonOut(["error" => "Invalid folder name"], 400);
|
||||
return;
|
||||
}
|
||||
if ($oldName === '' || !preg_match(REGEX_FILE_NAME, $oldName)) {
|
||||
$this->_jsonOut(["error" => "Invalid old file name."], 400);
|
||||
return;
|
||||
}
|
||||
if ($newName === '' || !preg_match(REGEX_FILE_NAME, $newName)) {
|
||||
$this->_jsonOut(["error" => "Invalid new file name."], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$oldName = basename(trim($data['oldName']));
|
||||
$newName = basename(trim($data['newName']));
|
||||
// Non-admin must own the original
|
||||
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
|
||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||
|
||||
// Validate file names.
|
||||
if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) {
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate the renaming operation to the model.
|
||||
$result = FileModel::renameFile($folder, $oldName, $newName);
|
||||
echo json_encode($result);
|
||||
if (!is_array($result)) {
|
||||
throw new RuntimeException('FileModel::renameFile returned non-array');
|
||||
}
|
||||
if (isset($result['error'])) {
|
||||
$this->_jsonOut($result, 400);
|
||||
return;
|
||||
}
|
||||
$this->_jsonOut($result);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::renameFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while renaming file.'], 500);
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
@@ -452,63 +621,75 @@ class FileController
|
||||
* @return void Outputs a JSON response.
|
||||
*/
|
||||
public function saveFile()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = $headersArr['x-csrf-token'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Authentication Check ---
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
// --- Read‑only check ---
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username && !empty($userPermissions['readOnly'])) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to save files."]);
|
||||
exit;
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
|
||||
$this->_jsonOut(["error" => "Read-only users are not allowed to save files."], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Input parsing ---
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$data = $this->_readJsonBody();
|
||||
if (empty($data) || !isset($data["fileName"], $data["content"])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid request data", "received" => $data]);
|
||||
exit;
|
||||
$this->_jsonOut(["error" => "Invalid request data"], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$fileName = basename($data["fileName"]);
|
||||
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
|
||||
$fileName = basename(trim((string)$data["fileName"]));
|
||||
$folder = isset($data["folder"]) ? trim((string)$data["folder"]) : "root";
|
||||
|
||||
// --- Folder validation ---
|
||||
if ($fileName === '' || !preg_match(REGEX_FILE_NAME, $fileName)) {
|
||||
$this->_jsonOut(["error" => "Invalid file name."], 400);
|
||||
return;
|
||||
}
|
||||
if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name"]);
|
||||
exit;
|
||||
$this->_jsonOut(["error" => "Invalid folder name"], 400);
|
||||
return;
|
||||
}
|
||||
$folder = trim($folder, "/\\ ");
|
||||
|
||||
// --- Delegate to model, passing the uploader ---
|
||||
// Make sure FileModel::saveFile signature is:
|
||||
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null)
|
||||
$result = FileModel::saveFile(
|
||||
$folder,
|
||||
$fileName,
|
||||
$data["content"],
|
||||
$username // ← pass the real uploader here
|
||||
);
|
||||
// Folder-only users may only write within their scope
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
echo json_encode($result);
|
||||
// If overwriting, enforce ownership for non-admins
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$dir = (strtolower($folder) === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder;
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $fileName;
|
||||
if (is_file($path)) {
|
||||
$violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions);
|
||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||
}
|
||||
|
||||
// Server-side guard: block saving executable/server-side script types
|
||||
$deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi'];
|
||||
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, $deny, true)) {
|
||||
$this->_jsonOut(['error' => 'Saving this file type is not allowed.'], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = FileModel::saveFile($folder, $fileName, (string)$data["content"], $username);
|
||||
if (!is_array($result)) {
|
||||
throw new RuntimeException('FileModel::saveFile returned non-array');
|
||||
}
|
||||
if (isset($result['error'])) {
|
||||
$this->_jsonOut($result, 400);
|
||||
return;
|
||||
}
|
||||
$this->_jsonOut($result);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::saveFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while saving file.'], 500);
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
@@ -582,6 +763,23 @@ class FileController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ownership enforcement (allow admin OR bypassOwnership)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
if (!$ignoreOwnership) {
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Retrieve download info from the model.
|
||||
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
|
||||
if (isset($downloadInfo['error'])) {
|
||||
@@ -676,6 +874,13 @@ class FileController
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$this->isAdmin($userPermissions) && array_key_exists('canZip', $userPermissions) && !$userPermissions['canZip']) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "ZIP downloads are not allowed for your account."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read and decode JSON input.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
|
||||
@@ -701,6 +906,22 @@ class FileController
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership enforcement (allow admin OR bypassOwnership)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
if (!$ignoreOwnership) {
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Create ZIP archive using FileModel.
|
||||
$result = FileModel::createZipArchive($folder, $files);
|
||||
if (isset($result['error'])) {
|
||||
@@ -819,6 +1040,12 @@ class FileController
|
||||
}
|
||||
}
|
||||
|
||||
// Folder-only users can only extract inside their subtree
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
|
||||
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
|
||||
|
||||
// Delegate to the model.
|
||||
$result = FileModel::extractZipArchive($folder, $files);
|
||||
echo json_encode($result);
|
||||
@@ -1078,13 +1305,19 @@ class FileController
|
||||
|
||||
// Check user permissions.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username && !empty($userPermissions['readOnly'])) {
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Read-only users are not allowed to create share links."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$this->isAdmin($userPermissions) && array_key_exists('canShare', $userPermissions) && !$userPermissions['canShare']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "You are not allowed to create share links."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Parse POST JSON input.
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
if (!$input) {
|
||||
@@ -1107,6 +1340,23 @@ class FileController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Non-admins can only share their own files
|
||||
// Ownership enforcement (allow admin OR bypassOwnership)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
if (!$ignoreOwnership) {
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the provided value+unit into seconds
|
||||
switch ($unit) {
|
||||
case 'seconds':
|
||||
@@ -1349,7 +1599,7 @@ class FileController
|
||||
// Delegate deletion to the model.
|
||||
$result = FileModel::deleteTrashFiles($filesToDelete);
|
||||
|
||||
// Build a human‑friendly success or error message
|
||||
// Build a human-friendly success or error message
|
||||
if (!empty($result['deleted'])) {
|
||||
$count = count($result['deleted']);
|
||||
$msg = "Trash item" . ($count === 1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']);
|
||||
@@ -1469,7 +1719,7 @@ class FileController
|
||||
|
||||
// Check that the user is not read-only.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to file tags"]);
|
||||
exit;
|
||||
@@ -1502,6 +1752,22 @@ class FileController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ownership enforcement (allow admin OR bypassOwnership)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
if (!$ignoreOwnership) {
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to the model.
|
||||
$result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete);
|
||||
echo json_encode($result);
|
||||
@@ -1545,32 +1811,96 @@ class FileController
|
||||
* @return void Outputs JSON response.
|
||||
*/
|
||||
public function getFileList(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Ensure user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
set_error_handler(function ($severity, $message, $file, $line) {
|
||||
if (!(error_reporting() & $severity)) return;
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
try {
|
||||
if (empty($_SESSION['username'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve the folder from GET; default to "root".
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
if (!is_dir(META_DIR)) {
|
||||
@mkdir(META_DIR, 0775, true);
|
||||
}
|
||||
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir(UPLOAD_DIR)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Uploads directory not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the model.
|
||||
$result = FileModel::getFileList($folder);
|
||||
|
||||
if ($result === false || $result === null) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'File model failed.']);
|
||||
return;
|
||||
}
|
||||
if (!is_array($result)) {
|
||||
throw new RuntimeException('FileModel::getFileList returned a non-array.');
|
||||
}
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode($result);
|
||||
return;
|
||||
}
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
|
||||
// --- viewOwnOnly (for non-admins) ---
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = $this->loadPerms($username);
|
||||
$admin = $this->isAdmin($perms);
|
||||
$ownOnly = !$admin && !empty($perms['viewOwnOnly']);
|
||||
|
||||
if ($ownOnly && isset($result['files'])) {
|
||||
$files = $result['files'];
|
||||
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
|
||||
// associative: name => meta
|
||||
$filtered = [];
|
||||
foreach ($files as $name => $meta) {
|
||||
if (!isset($meta['uploader']) || strcasecmp((string)$meta['uploader'], $username) === 0) {
|
||||
$filtered[$name] = $meta;
|
||||
}
|
||||
}
|
||||
$result['files'] = $filtered;
|
||||
} elseif (is_array($files)) {
|
||||
// list of objects
|
||||
$result['files'] = array_values(array_filter($files, function ($f) use ($username) {
|
||||
return !isset($f['uploader']) || strcasecmp((string)$f['uploader'], $username) === 0;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::getFileList error: ' . $e->getMessage() .
|
||||
' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Internal server error while listing files.']);
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/file/getShareLinks.php
|
||||
@@ -1631,26 +1961,44 @@ class FileController
|
||||
* POST /api/file/createFile.php
|
||||
*/
|
||||
public function createFile(): void
|
||||
{
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
// Check user permissions (assuming loadUserPermissions() is available).
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (!empty($userPermissions['readOnly'])) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to create files."]);
|
||||
exit;
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
|
||||
$this->_jsonOut(["error" => "Read-only users are not allowed to create files."], 403);
|
||||
return;
|
||||
}
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$folder = $body['folder'] ?? 'root';
|
||||
$filename = $body['name'] ?? '';
|
||||
|
||||
$result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown');
|
||||
$body = $this->_readJsonBody();
|
||||
$folder = isset($body['folder']) ? trim((string)$body['folder']) : 'root';
|
||||
$filename = isset($body['name']) ? basename(trim((string)$body['name'])) : '';
|
||||
|
||||
if (!$result['success']) {
|
||||
http_response_code($result['code'] ?? 400);
|
||||
echo json_encode(['success'=>false,'error'=>$result['error']]);
|
||||
} else {
|
||||
echo json_encode(['success'=>true]);
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
|
||||
}
|
||||
if ($filename === '' || !preg_match(REGEX_FILE_NAME, $filename)) {
|
||||
$this->_jsonOut(["error" => "Invalid file name."], 400); return;
|
||||
}
|
||||
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
$result = FileModel::createFile($folder, $filename, $username);
|
||||
if (empty($result['success'])) {
|
||||
$this->_jsonOut(['success'=>false,'error'=>$result['error'] ?? 'Failed to create file'], $result['code'] ?? 400);
|
||||
return;
|
||||
}
|
||||
$this->_jsonOut(['success'=>true]);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::createFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while creating file.'], 500);
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,106 @@
|
||||
<?php
|
||||
// UserController.php located in src/controllers/
|
||||
// src/controllers/UserController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||
|
||||
/**
|
||||
* UserController
|
||||
* - Hardened CSRF/auth checks (works even when getallheaders() is unavailable)
|
||||
* - Consistent method checks without breaking existing clients (accepts POST as fallback for some endpoints)
|
||||
* - Stricter validation & safer defaults
|
||||
* - Fixed TOTP setup bug for pending-login users
|
||||
* - Standardized calls to UserModel (proper case)
|
||||
*/
|
||||
class UserController
|
||||
{
|
||||
/* ---------- Small internal helpers to reduce repetition ---------- */
|
||||
|
||||
/** Get headers in lowercase, robust across SAPIs. */
|
||||
private static function headersLower(): array
|
||||
{
|
||||
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
||||
$out = [];
|
||||
foreach ($headers as $k => $v) {
|
||||
$out[strtolower($k)] = $v;
|
||||
}
|
||||
// Fallbacks from $_SERVER if needed
|
||||
foreach ($_SERVER as $k => $v) {
|
||||
if (strpos($k, 'HTTP_') === 0) {
|
||||
$h = strtolower(str_replace('_', '-', substr($k, 5)));
|
||||
if (!isset($out[$h])) $out[$h] = $v;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Enforce allowed HTTP method(s); default to 405 if not allowed. */
|
||||
private static function requireMethod(array $allowed): void
|
||||
{
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
if (!in_array($method, $allowed, true)) {
|
||||
http_response_code(405);
|
||||
header('Allow: ' . implode(', ', $allowed));
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforce authentication (401). */
|
||||
private static function requireAuth(): void
|
||||
{
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforce admin (401). */
|
||||
private static function requireAdmin(): void
|
||||
{
|
||||
self::requireAuth();
|
||||
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
|
||||
private static function requireCsrf(): void
|
||||
{
|
||||
$h = self::headersLower();
|
||||
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read JSON body (empty array if not valid). */
|
||||
private static function readJson(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/** Convenience: set JSON content type + no-store. */
|
||||
private static function jsonHeaders(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
}
|
||||
|
||||
/* ------------------------- End helpers -------------------------- */
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/getUsers.php",
|
||||
@@ -31,24 +126,15 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function getUsers()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication and admin privileges.
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
self::jsonHeaders();
|
||||
self::requireAdmin();
|
||||
|
||||
// Retrieve users using the model
|
||||
$users = userModel::getAllUsers();
|
||||
$users = UserModel::getAllUsers();
|
||||
echo json_encode($users);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,34 +170,33 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function addUser()
|
||||
{
|
||||
// 1) Ensure JSON output and session
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
self::requireMethod(['POST']);
|
||||
|
||||
// 1a) Initialize CSRF token if missing
|
||||
// Initialize CSRF token if missing (useful for initial page load)
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
// 2) Determine setup mode (first-ever admin creation)
|
||||
// Setup mode detection (first-run bootstrap)
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
||||
$setupMode = false;
|
||||
if (
|
||||
$isSetup && (! file_exists($usersFile)
|
||||
$isSetup && (!file_exists($usersFile)
|
||||
|| filesize($usersFile) === 0
|
||||
|| trim(file_get_contents($usersFile)) === ''
|
||||
|| trim(@file_get_contents($usersFile)) === ''
|
||||
)
|
||||
) {
|
||||
$setupMode = true;
|
||||
} else {
|
||||
// 3) In non-setup, enforce CSRF + auth checks
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
||||
// Not setup: enforce CSRF + admin auth
|
||||
$h = self::headersLower();
|
||||
$receivedToken = trim($h['x-csrf-token'] ?? '');
|
||||
|
||||
// 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
|
||||
// Soft-fail CSRF: on mismatch, regenerate and return new token (preserve your current UX)
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
||||
@@ -122,31 +207,15 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3b) Must be logged in as admin
|
||||
if (
|
||||
empty($_SESSION['authenticated'])
|
||||
|| $_SESSION['authenticated'] !== true
|
||||
|| empty($_SESSION['isAdmin'])
|
||||
|| $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
self::requireAdmin();
|
||||
}
|
||||
|
||||
// 4) Parse input
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$data = self::readJson();
|
||||
$newUsername = trim($data['username'] ?? '');
|
||||
$newPassword = trim($data['password'] ?? '');
|
||||
|
||||
// 5) Determine admin flag
|
||||
if ($setupMode) {
|
||||
$isAdmin = '1';
|
||||
} else {
|
||||
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
|
||||
}
|
||||
$isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0');
|
||||
|
||||
// 6) Validate fields
|
||||
if ($newUsername === '' || $newPassword === '') {
|
||||
echo json_encode(["error" => "Username and password required"]);
|
||||
exit;
|
||||
@@ -157,11 +226,13 @@ class UserController
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// Keep password rules lenient to avoid breaking existing flows; enforce at least 6 chars
|
||||
if (strlen($newPassword) < 6) {
|
||||
echo json_encode(["error" => "Password must be at least 6 characters."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 7) Delegate to model
|
||||
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
||||
|
||||
// 8) Return model result
|
||||
$result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
@@ -201,54 +272,33 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function removeUser()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
// Accept DELETE or POST for broader compatibility
|
||||
self::requireMethod(['DELETE', 'POST']);
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
// CSRF token check.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
$data = self::readJson();
|
||||
$usernameToRemove = trim($data['username'] ?? '');
|
||||
|
||||
// Authentication and admin check.
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Retrieve JSON data.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$usernameToRemove = trim($data["username"] ?? "");
|
||||
|
||||
if (!$usernameToRemove) {
|
||||
if ($usernameToRemove === '') {
|
||||
echo json_encode(["error" => "Username is required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate the username format.
|
||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||
echo json_encode(["error" => "Invalid username format"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Prevent removal of the currently logged-in user.
|
||||
if (isset($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
|
||||
if (!empty($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
|
||||
echo json_encode(["error" => "Cannot remove yourself"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate the removal logic to the model.
|
||||
$result = userModel::removeUser($usernameToRemove);
|
||||
$result = UserModel::removeUser($usernameToRemove);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,21 +319,14 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function getUserPermissions()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
self::requireAuth();
|
||||
|
||||
// Check if the user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate to the model.
|
||||
$permissions = userModel::getUserPermissions();
|
||||
$permissions = UserModel::getUserPermissions();
|
||||
echo json_encode($permissions);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,42 +374,24 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function updateUserPermissions()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
// Accept PUT or POST for compatibility with clients that can't send PUT
|
||||
self::requireMethod(['PUT', 'POST']);
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
// Only admins can update permissions.
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify CSRF token from headers.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get POST input.
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
$input = self::readJson();
|
||||
if (!isset($input['permissions']) || !is_array($input['permissions'])) {
|
||||
echo json_encode(["error" => "Invalid input"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$permissions = $input['permissions'];
|
||||
|
||||
// Delegate to the model.
|
||||
$result = userModel::updateUserPermissions($permissions);
|
||||
$result = UserModel::updateUserPermissions($permissions);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,41 +431,25 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function changePassword()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
self::jsonHeaders();
|
||||
self::requireMethod(['POST']);
|
||||
self::requireAuth();
|
||||
self::requireCsrf();
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
if ($username === '') {
|
||||
echo json_encode(["error" => "No username in session"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF token check.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get POST data.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||
$newPassword = trim($data["newPassword"] ?? "");
|
||||
$data = self::readJson();
|
||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||
$newPassword = trim($data["newPassword"] ?? "");
|
||||
$confirmPassword = trim($data["confirmPassword"] ?? "");
|
||||
|
||||
// Validate input.
|
||||
if (!$oldPassword || !$newPassword || !$confirmPassword) {
|
||||
if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') {
|
||||
echo json_encode(["error" => "All fields are required."]);
|
||||
exit;
|
||||
}
|
||||
@@ -448,10 +457,14 @@ class UserController
|
||||
echo json_encode(["error" => "New passwords do not match."]);
|
||||
exit;
|
||||
}
|
||||
if (strlen($newPassword) < 6) {
|
||||
echo json_encode(["error" => "Password must be at least 6 characters."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate password change logic to the model.
|
||||
$result = userModel::changePassword($username, $oldPassword, $newPassword);
|
||||
$result = UserModel::changePassword($username, $oldPassword, $newPassword);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,29 +502,15 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function updateUserPanel()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
// Accept PUT or POST for compatibility
|
||||
self::requireMethod(['PUT', 'POST']);
|
||||
self::requireAuth();
|
||||
self::requireCsrf();
|
||||
|
||||
// Check if the user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify the CSRF token.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get the POST input.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$data = self::readJson();
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid input"]);
|
||||
@@ -519,18 +518,16 @@ class UserController
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
if ($username === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "No username in session"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Extract totp_enabled, converting it to boolean.
|
||||
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
|
||||
|
||||
// Delegate to the model.
|
||||
$result = userModel::updateUserPanel($username, $totp_enabled);
|
||||
$result = UserModel::updateUserPanel($username, $totp_enabled);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -558,43 +555,29 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function disableTOTP()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Authentication check.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Not authenticated"]);
|
||||
exit;
|
||||
}
|
||||
self::jsonHeaders();
|
||||
// Accept PUT or POST
|
||||
self::requireMethod(['PUT', 'POST']);
|
||||
self::requireAuth();
|
||||
self::requireCsrf();
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (empty($username)) {
|
||||
if ($username === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Username not found in session"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF token check.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate the TOTP disabling logic to the model.
|
||||
$result = userModel::disableTOTPSecret($username);
|
||||
|
||||
$result = UserModel::disableTOTPSecret($username);
|
||||
if ($result) {
|
||||
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to disable TOTP."]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,61 +619,45 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function recoverTOTP()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
self::requireMethod(['POST']);
|
||||
self::requireCsrf();
|
||||
|
||||
// 1) Only allow POST.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
|
||||
}
|
||||
|
||||
// 2) CSRF check.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
|
||||
}
|
||||
|
||||
// 3) Identify the user.
|
||||
$userId = $_SESSION['username'] ?? $_SESSION['pending_login_user'] ?? null;
|
||||
$userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null);
|
||||
if (!$userId) {
|
||||
http_response_code(401);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4) Validate userId format.
|
||||
if (!preg_match(REGEX_USER, $userId)) {
|
||||
http_response_code(400);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5) Get the recovery code from input.
|
||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
||||
$inputData = self::readJson();
|
||||
$recoveryCode = $inputData['recovery_code'] ?? '';
|
||||
|
||||
// 6) Delegate to the model.
|
||||
$result = userModel::recoverTOTP($userId, $recoveryCode);
|
||||
$result = UserModel::recoverTOTP($userId, $recoveryCode);
|
||||
|
||||
if ($result['status'] === 'ok') {
|
||||
// 7) Finalize login.
|
||||
if (($result['status'] ?? '') === 'ok') {
|
||||
// Finalize login
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $userId;
|
||||
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
|
||||
echo json_encode(['status' => 'ok']);
|
||||
} else {
|
||||
// Set appropriate HTTP code for errors.
|
||||
if ($result['message'] === 'Too many attempts. Try again later.') {
|
||||
if (($result['message'] ?? '') === 'Too many attempts. Try again later.') {
|
||||
http_response_code(429);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
}
|
||||
echo json_encode($result);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -722,49 +689,33 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function saveTOTPRecoveryCode()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
self::requireMethod(['POST']);
|
||||
self::requireCsrf();
|
||||
|
||||
// 1) Only allow POST requests.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}");
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
|
||||
}
|
||||
|
||||
// 2) CSRF token check.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
|
||||
}
|
||||
|
||||
// 3) Ensure the user is authenticated.
|
||||
if (empty($_SESSION['username'])) {
|
||||
http_response_code(401);
|
||||
error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}");
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4) Validate the username format.
|
||||
$userId = $_SESSION['username'];
|
||||
if (!preg_match(REGEX_USER, $userId)) {
|
||||
http_response_code(400);
|
||||
error_log("totp_saveCode: invalid username format: {$userId}");
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5) Delegate to the model.
|
||||
$result = userModel::saveTOTPRecoveryCode($userId);
|
||||
if ($result['status'] === 'ok') {
|
||||
$result = UserModel::saveTOTPRecoveryCode($userId);
|
||||
if (($result['status'] ?? '') === 'ok') {
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode($result);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,43 +742,40 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function setupTOTP()
|
||||
{
|
||||
// Allow access if the user is authenticated or pending TOTP.
|
||||
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) {
|
||||
// Allow access if authenticated OR pending TOTP
|
||||
if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(["error" => "Not authorized to access TOTP setup"]));
|
||||
}
|
||||
|
||||
// Verify CSRF token from headers.
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Not authorized to access TOTP setup"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
self::requireCsrf();
|
||||
|
||||
// Fix: if username not present (pending flow), fall back to pending_login_user
|
||||
$username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? '');
|
||||
if ($username === '') {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Username not available for TOTP setup']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set header for PNG output.
|
||||
header("Content-Type: image/png");
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Delegate the TOTP setup work to the model.
|
||||
$result = userModel::setupTOTP($username);
|
||||
$result = UserModel::setupTOTP($username);
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => $result['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Output the QR code image.
|
||||
echo $result['imageData'];
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -866,11 +814,11 @@ class UserController
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function verifyTOTP()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Rate-limit
|
||||
if (!isset($_SESSION['totp_failures'])) {
|
||||
@@ -890,16 +838,10 @@ class UserController
|
||||
}
|
||||
|
||||
// CSRF check
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
||||
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
self::requireCsrf();
|
||||
|
||||
// Parse & validate input
|
||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
||||
$inputData = self::readJson();
|
||||
$code = trim($inputData['totp_code'] ?? '');
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
http_response_code(400);
|
||||
@@ -916,11 +858,11 @@ class UserController
|
||||
\RobThree\Auth\Algorithm::Sha1
|
||||
);
|
||||
|
||||
// === Pending-login flow (we just came from auth and need to finish login) ===
|
||||
// Pending-login flow
|
||||
if (isset($_SESSION['pending_login_user'])) {
|
||||
$username = $_SESSION['pending_login_user'];
|
||||
$username = $_SESSION['pending_login_user'];
|
||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
||||
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
||||
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
||||
|
||||
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
||||
$_SESSION['totp_failures']++;
|
||||
@@ -939,13 +881,14 @@ class UserController
|
||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||
$all = json_decode($dec, true) ?: [];
|
||||
}
|
||||
$perms = loadUserPermissions($username);
|
||||
$all[$token] = [
|
||||
'username' => $username,
|
||||
'expiry' => $expiry,
|
||||
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
||||
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
||||
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
||||
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
|
||||
'username' => $username,
|
||||
'expiry' => $expiry,
|
||||
'isAdmin' => ((int)UserModel::getUserRole($username) === 1),
|
||||
'folderOnly' => $perms['folderOnly'] ?? false,
|
||||
'readOnly' => $perms['readOnly'] ?? false,
|
||||
'disableUpload' => $perms['disableUpload'] ?? false
|
||||
];
|
||||
file_put_contents(
|
||||
$tokFile,
|
||||
@@ -957,17 +900,16 @@ class UserController
|
||||
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
// === Finalize login into session exactly as finalizeLogin() would ===
|
||||
// Finalize login
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $username;
|
||||
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
||||
$_SESSION['isAdmin'] = ((int)UserModel::getUserRole($username) === 1);
|
||||
$perms = loadUserPermissions($username);
|
||||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||
|
||||
// Clean up pending markers
|
||||
unset(
|
||||
$_SESSION['pending_login_user'],
|
||||
$_SESSION['pending_login_secret'],
|
||||
@@ -975,7 +917,6 @@ class UserController
|
||||
$_SESSION['totp_failures']
|
||||
);
|
||||
|
||||
// Send back full login payload
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'success' => 'Login successful',
|
||||
@@ -990,13 +931,13 @@ class UserController
|
||||
|
||||
// Setup/verification flow (not pending)
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
if ($username === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$totpSecret = userModel::getTOTPSecret($username);
|
||||
$totpSecret = UserModel::getTOTPSecret($username);
|
||||
if (!$totpSecret) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
|
||||
@@ -1010,34 +951,22 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Successful setup/verification
|
||||
unset($_SESSION['totp_failures']);
|
||||
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload profile picture (multipart/form-data)
|
||||
*/
|
||||
public function uploadPicture()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::jsonHeaders();
|
||||
|
||||
// 1) Auth check
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
// Auth & CSRF
|
||||
self::requireAuth();
|
||||
self::requireCsrf();
|
||||
|
||||
// 2) CSRF check
|
||||
$headers = function_exists('getallheaders')
|
||||
? array_change_key_case(getallheaders(), CASE_LOWER)
|
||||
: [];
|
||||
$csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3) File presence
|
||||
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||
@@ -1045,7 +974,7 @@ class UserController
|
||||
}
|
||||
$file = $_FILES['profile_picture'];
|
||||
|
||||
// 4) Validate MIME & size
|
||||
// Validate MIME & size
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
@@ -1061,32 +990,29 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5) Destination under public/uploads/profile_pics
|
||||
$uploadDir = UPLOAD_DIR . '/profile_pics';
|
||||
// Destination
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
|
||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6) Move file
|
||||
$ext = $allowed[$mime];
|
||||
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
|
||||
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = "$uploadDir/$filename";
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 7) Build public URL
|
||||
// Assuming /uploads maps to UPLOAD_DIR publicly
|
||||
$url = '/uploads/profile_pics/' . $filename;
|
||||
|
||||
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
|
||||
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
|
||||
if (!$result['success']) {
|
||||
// on failure, remove the file we just wrote
|
||||
if (!($result['success'] ?? false)) {
|
||||
@unlink($dest);
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
@@ -1095,9 +1021,7 @@ class UserController
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
// 8) Return success
|
||||
echo json_encode(['success' => true, 'url' => $url]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -6,25 +6,60 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
class AdminModel
|
||||
{
|
||||
/**
|
||||
* Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes.
|
||||
* Parse a shorthand size value (e.g. "5G", "500M", "123K", "50MB", "10KiB") into bytes.
|
||||
* Accepts bare numbers (bytes) and common suffixes: K, KB, KiB, M, MB, MiB, G, GB, GiB, etc.
|
||||
*
|
||||
* @param string $val
|
||||
* @return int
|
||||
* @return int Bytes (rounded)
|
||||
*/
|
||||
private static function parseSize(string $val): int
|
||||
{
|
||||
$unit = strtolower(substr($val, -1));
|
||||
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY');
|
||||
switch ($unit) {
|
||||
case 'g':
|
||||
return $num * 1024 ** 3;
|
||||
case 'm':
|
||||
return $num * 1024 ** 2;
|
||||
case 'k':
|
||||
return $num * 1024;
|
||||
default:
|
||||
return $num;
|
||||
$val = trim($val);
|
||||
if ($val === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Match: number + optional unit/suffix (K, KB, KiB, M, MB, MiB, G, GB, GiB, ...)
|
||||
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*([kmgtpezy]?i?b?)?\s*$/i', $val, $m)) {
|
||||
$num = (float)$m[1];
|
||||
$unit = strtolower($m[2] ?? '');
|
||||
|
||||
switch ($unit) {
|
||||
case 'k': case 'kb': case 'kib':
|
||||
$num *= 1024;
|
||||
break;
|
||||
case 'm': case 'mb': case 'mib':
|
||||
$num *= 1024 ** 2;
|
||||
break;
|
||||
case 'g': case 'gb': case 'gib':
|
||||
$num *= 1024 ** 3;
|
||||
break;
|
||||
case 't': case 'tb': case 'tib':
|
||||
$num *= 1024 ** 4;
|
||||
break;
|
||||
case 'p': case 'pb': case 'pib':
|
||||
$num *= 1024 ** 5;
|
||||
break;
|
||||
case 'e': case 'eb': case 'eib':
|
||||
$num *= 1024 ** 6;
|
||||
break;
|
||||
case 'z': case 'zb': case 'zib':
|
||||
$num *= 1024 ** 7;
|
||||
break;
|
||||
case 'y': case 'yb': case 'yib':
|
||||
$num *= 1024 ** 8;
|
||||
break;
|
||||
// case 'b' or empty => bytes; do nothing
|
||||
default:
|
||||
// If unit is just 'b' or empty, treat as bytes.
|
||||
// For unknown units fall back to bytes.
|
||||
break;
|
||||
}
|
||||
return (int) round($num);
|
||||
}
|
||||
|
||||
// Fallback: cast any unrecognized input to int (bytes)
|
||||
return (int)$val;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,14 +70,24 @@ 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."];
|
||||
// Ensure encryption key exists
|
||||
if (empty($GLOBALS['encryptionKey']) || !is_string($GLOBALS['encryptionKey'])) {
|
||||
return ["error" => "Server encryption key is not configured."];
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -67,7 +112,7 @@ class AdminModel
|
||||
$configUpdate['sharedMaxUploadSize'] = $sms;
|
||||
}
|
||||
|
||||
// ── NEW: normalize authBypass & authHeaderName ─────────────────────────
|
||||
// Normalize authBypass & authHeaderName
|
||||
if (!isset($configUpdate['loginOptions']['authBypass'])) {
|
||||
$configUpdate['loginOptions']['authBypass'] = false;
|
||||
}
|
||||
@@ -80,10 +125,8 @@ class AdminModel
|
||||
) {
|
||||
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
|
||||
} else {
|
||||
$configUpdate['loginOptions']['authHeaderName'] =
|
||||
trim($configUpdate['loginOptions']['authHeaderName']);
|
||||
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Convert configuration to JSON.
|
||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||
@@ -104,13 +147,15 @@ class AdminModel
|
||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||
// Attempt a cleanup: delete the old file and try again.
|
||||
if (file_exists($configFile)) {
|
||||
unlink($configFile);
|
||||
@unlink($configFile);
|
||||
}
|
||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
|
||||
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."];
|
||||
}
|
||||
@@ -123,13 +168,15 @@ class AdminModel
|
||||
public static function getConfig(): array
|
||||
{
|
||||
$configFile = USERS_DIR . 'adminConfig.json';
|
||||
|
||||
if (file_exists($configFile)) {
|
||||
$encryptedContent = file_get_contents($configFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
|
||||
if ($decryptedContent === false) {
|
||||
http_response_code(500);
|
||||
// Do not set HTTP status here; let the controller decide.
|
||||
return ["error" => "Failed to decrypt configuration."];
|
||||
}
|
||||
|
||||
$config = json_decode($decryptedContent, true);
|
||||
if (!is_array($config)) {
|
||||
$config = [];
|
||||
@@ -137,19 +184,38 @@ 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;
|
||||
}
|
||||
|
||||
// Ensure OIDC structure exists
|
||||
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] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize authBypass & authHeaderName
|
||||
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
||||
$config['loginOptions']['authBypass'] = false;
|
||||
} else {
|
||||
@@ -167,38 +233,41 @@ class AdminModel
|
||||
if (!isset($config['globalOtpauthUrl'])) {
|
||||
$config['globalOtpauthUrl'] = "";
|
||||
}
|
||||
if (!isset($config['header_title']) || empty($config['header_title'])) {
|
||||
if (!isset($config['header_title']) || $config['header_title'] === '') {
|
||||
$config['header_title'] = "FileRise";
|
||||
}
|
||||
if (!isset($config['enableWebDAV'])) {
|
||||
$config['enableWebDAV'] = false;
|
||||
}
|
||||
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
|
||||
if (!isset($config['sharedMaxUploadSize'])) {
|
||||
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE));
|
||||
$config['sharedMaxUploadSize'] = $defaultSms;
|
||||
|
||||
// sharedMaxUploadSize: default if missing; clamp if present
|
||||
$maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
|
||||
if (!isset($config['sharedMaxUploadSize']) || !is_numeric($config['sharedMaxUploadSize']) || $config['sharedMaxUploadSize'] < 1) {
|
||||
$config['sharedMaxUploadSize'] = min(50 * 1024 * 1024, $maxBytes);
|
||||
} else {
|
||||
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
|
||||
}
|
||||
|
||||
return $config;
|
||||
} else {
|
||||
// Return defaults.
|
||||
return [
|
||||
'header_title' => "FileRise",
|
||||
'oidc' => [
|
||||
'providerUrl' => 'https://your-oidc-provider.com',
|
||||
'clientId' => '',
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin' => false
|
||||
],
|
||||
'globalOtpauthUrl' => "",
|
||||
'enableWebDAV' => false,
|
||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||
];
|
||||
}
|
||||
|
||||
// No config on disk; return defaults.
|
||||
return [
|
||||
'header_title' => "FileRise",
|
||||
'oidc' => [
|
||||
'providerUrl' => 'https://your-oidc-provider.com',
|
||||
'clientId' => '',
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin' => true
|
||||
],
|
||||
'globalOtpauthUrl' => "",
|
||||
'enableWebDAV' => false,
|
||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,58 +6,61 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
class FolderModel
|
||||
{
|
||||
/**
|
||||
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
||||
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
|
||||
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
|
||||
* containment, and (optionally) creates the folder.
|
||||
*
|
||||
* @param string $folderName The name of the folder to create.
|
||||
* @param string $parent (Optional) The parent folder name. Defaults to empty.
|
||||
* @return array Returns an array with a "success" key if the folder was created,
|
||||
* or an "error" key if an error occurred.
|
||||
* @param string $folder Relative folder or "root"
|
||||
* @param bool $create Create the folder if missing
|
||||
* @return array [string|null $realPath, string $relative, string|null $error]
|
||||
*/
|
||||
public static function createFolder(string $folderName, string $parent = ""): array
|
||||
private static function resolveFolderPath(string $folder, bool $create = false): array
|
||||
{
|
||||
$folderName = trim($folderName);
|
||||
$parent = trim($parent);
|
||||
$folder = trim($folder) ?: 'root';
|
||||
$relative = 'root';
|
||||
|
||||
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed).
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
||||
return ["error" => "Invalid parent folder name."];
|
||||
$base = realpath(UPLOAD_DIR);
|
||||
if ($base === false) {
|
||||
return [null, 'root', "Uploads directory not configured correctly."];
|
||||
}
|
||||
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
if ($parent !== "" && strtolower($parent) !== "root") {
|
||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
|
||||
$relativePath = $parent . "/" . $folderName;
|
||||
if (strtolower($folder) === 'root') {
|
||||
$dir = $base;
|
||||
} else {
|
||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
|
||||
$relativePath = $folderName;
|
||||
}
|
||||
|
||||
// Check if the folder already exists.
|
||||
if (file_exists($fullPath)) {
|
||||
return ["error" => "Folder already exists."];
|
||||
}
|
||||
|
||||
// Attempt to create the folder.
|
||||
if (mkdir($fullPath, 0755, true)) {
|
||||
// Create an empty metadata file for the new folder.
|
||||
$metadataFile = self::getMetadataFilePath($relativePath);
|
||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
|
||||
return ["error" => "Folder created but failed to create metadata file."];
|
||||
// validate each segment against REGEX_FOLDER_NAME
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
|
||||
if (empty($parts)) {
|
||||
return [null, 'root', "Invalid folder name."];
|
||||
}
|
||||
return ["success" => true];
|
||||
} else {
|
||||
return ["error" => "Failed to create folder."];
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
return [null, 'root', "Invalid folder name."];
|
||||
}
|
||||
}
|
||||
$relative = implode('/', $parts);
|
||||
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
}
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if ($create) {
|
||||
if (!mkdir($dir, 0775, true)) {
|
||||
return [null, $relative, "Failed to create folder."];
|
||||
}
|
||||
} else {
|
||||
return [null, $relative, "Folder does not exist."];
|
||||
}
|
||||
}
|
||||
|
||||
$real = realpath($dir);
|
||||
if ($real === false || strpos($real, $base) !== 0) {
|
||||
return [null, $relative, "Invalid folder path."];
|
||||
}
|
||||
|
||||
return [$real, $relative, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the metadata file path for a given folder.
|
||||
*
|
||||
* @param string $folder The relative folder path.
|
||||
* @return string The metadata file path.
|
||||
* Build metadata file path for a given (relative) folder.
|
||||
*/
|
||||
private static function getMetadataFilePath(string $folder): string
|
||||
{
|
||||
@@ -67,134 +70,146 @@ class FolderModel
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
||||
*/
|
||||
public static function createFolder(string $folderName, string $parent = ""): array
|
||||
{
|
||||
$folderName = trim($folderName);
|
||||
$parent = trim($parent);
|
||||
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
|
||||
// Resolve parent path (root ok; nested ok)
|
||||
[$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
$targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName);
|
||||
$targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
if (file_exists($targetDir)) {
|
||||
return ["error" => "Folder already exists."];
|
||||
}
|
||||
|
||||
if (!mkdir($targetDir, 0775, true)) {
|
||||
return ["error" => "Failed to create folder."];
|
||||
}
|
||||
|
||||
// Create an empty metadata file for the new folder.
|
||||
$metadataFile = self::getMetadataFilePath($targetRel);
|
||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
return ["error" => "Folder created but failed to create metadata file."];
|
||||
}
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a folder if it is empty and removes its corresponding metadata.
|
||||
*
|
||||
* @param string $folder The folder name (relative to the upload directory).
|
||||
* @return array An associative array with "success" on success or "error" on failure.
|
||||
*/
|
||||
public static function deleteFolder(string $folder): array
|
||||
{
|
||||
// Prevent deletion of "root".
|
||||
if (strtolower($folder) === 'root') {
|
||||
return ["error" => "Cannot delete root folder."];
|
||||
}
|
||||
|
||||
// Validate folder name.
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// Build the full folder path.
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
|
||||
|
||||
// Check if the folder exists and is a directory.
|
||||
if (!file_exists($folderPath) || !is_dir($folderPath)) {
|
||||
return ["error" => "Folder does not exist."];
|
||||
}
|
||||
|
||||
// Prevent deletion if the folder is not empty.
|
||||
$items = array_diff(scandir($folderPath), array('.', '..'));
|
||||
// Prevent deletion if not empty.
|
||||
$items = array_diff(scandir($real), array('.', '..'));
|
||||
if (count($items) > 0) {
|
||||
return ["error" => "Folder is not empty."];
|
||||
}
|
||||
|
||||
// Attempt to delete the folder.
|
||||
if (rmdir($folderPath)) {
|
||||
// Remove corresponding metadata file.
|
||||
$metadataFile = self::getMetadataFilePath($folder);
|
||||
if (file_exists($metadataFile)) {
|
||||
unlink($metadataFile);
|
||||
}
|
||||
return ["success" => true];
|
||||
} else {
|
||||
if (!rmdir($real)) {
|
||||
return ["error" => "Failed to delete folder."];
|
||||
}
|
||||
|
||||
// Remove metadata file (best-effort).
|
||||
$metadataFile = self::getMetadataFilePath($relative);
|
||||
if (file_exists($metadataFile)) {
|
||||
@unlink($metadataFile);
|
||||
}
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a folder and updates related metadata files.
|
||||
*
|
||||
* @param string $oldFolder The current folder name (relative to UPLOAD_DIR).
|
||||
* @param string $newFolder The new folder name.
|
||||
* @return array Returns an associative array with "success" on success or "error" on failure.
|
||||
* Renames a folder and updates related metadata files (by renaming their filenames).
|
||||
*/
|
||||
public static function renameFolder(string $oldFolder, string $newFolder): array
|
||||
{
|
||||
// Sanitize and trim folder names.
|
||||
$oldFolder = trim($oldFolder, "/\\ ");
|
||||
$newFolder = trim($newFolder, "/\\ ");
|
||||
|
||||
// Validate folder names.
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
|
||||
return ["error" => "Invalid folder name(s)."];
|
||||
// Validate names (per-segment)
|
||||
foreach ([$oldFolder, $newFolder] as $f) {
|
||||
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
|
||||
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
return ["error" => "Invalid folder name(s)."];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the full folder paths.
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
|
||||
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
|
||||
[$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// Validate that the old folder exists and new folder does not.
|
||||
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
|
||||
strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
|
||||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0
|
||||
) {
|
||||
$base = realpath(UPLOAD_DIR);
|
||||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||||
|
||||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
||||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||
|
||||
// Parent of new path must exist
|
||||
$newParent = dirname($newPath);
|
||||
if (!is_dir($newParent) || strpos(realpath($newParent), $base) !== 0) {
|
||||
return ["error" => "Invalid folder path."];
|
||||
}
|
||||
|
||||
if (!file_exists($oldPath) || !is_dir($oldPath)) {
|
||||
return ["error" => "Folder to rename does not exist."];
|
||||
}
|
||||
|
||||
if (file_exists($newPath)) {
|
||||
return ["error" => "New folder name already exists."];
|
||||
}
|
||||
|
||||
// Attempt to rename the folder.
|
||||
if (rename($oldPath, $newPath)) {
|
||||
// Update metadata: Rename all metadata files that have the old folder prefix.
|
||||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldFolder);
|
||||
$newPrefix = str_replace(['/', '\\', ' '], '-', $newFolder);
|
||||
$metadataFiles = glob(META_DIR . $oldPrefix . '*_metadata.json');
|
||||
foreach ($metadataFiles as $oldMetaFile) {
|
||||
$baseName = basename($oldMetaFile);
|
||||
$newBaseName = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
|
||||
$newMetaFile = META_DIR . $newBaseName;
|
||||
rename($oldMetaFile, $newMetaFile);
|
||||
}
|
||||
return ["success" => true];
|
||||
} else {
|
||||
if (!rename($oldReal, $newPath)) {
|
||||
return ["error" => "Failed to rename folder."];
|
||||
}
|
||||
|
||||
// Update metadata filenames (prefix-rename)
|
||||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
||||
$newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts));
|
||||
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
||||
$metadataFiles = glob($globPat) ?: [];
|
||||
|
||||
foreach ($metadataFiles as $oldMetaFile) {
|
||||
$baseName = basename($oldMetaFile);
|
||||
$newBase = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
|
||||
$newMeta = META_DIR . $newBase;
|
||||
@rename($oldMetaFile, $newMeta);
|
||||
}
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scans a directory for subfolders.
|
||||
*
|
||||
* @param string $dir The full path to the directory.
|
||||
* @param string $relative The relative path from the base directory.
|
||||
* @return array An array of folder paths (relative to the base).
|
||||
* Recursively scans a directory for subfolders (relative paths).
|
||||
*/
|
||||
private static function getSubfolders(string $dir, string $relative = ''): array
|
||||
{
|
||||
$folders = [];
|
||||
$items = scandir($dir);
|
||||
$safeFolderNamePattern = REGEX_FOLDER_NAME;
|
||||
$items = @scandir($dir) ?: [];
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match($safeFolderNamePattern, $item)) {
|
||||
continue;
|
||||
}
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $item)) continue;
|
||||
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_dir($path)) {
|
||||
$folderPath = ($relative ? $relative . '/' : '') . $item;
|
||||
$folders[] = $folderPath;
|
||||
$subFolders = self::getSubfolders($path, $folderPath);
|
||||
$folders = array_merge($folders, $subFolders);
|
||||
$folders[] = $folderPath;
|
||||
$folders = array_merge($folders, self::getSubfolders($path, $folderPath));
|
||||
}
|
||||
}
|
||||
return $folders;
|
||||
@@ -202,35 +217,31 @@ class FolderModel
|
||||
|
||||
/**
|
||||
* Retrieves the list of folders (including "root") along with file count metadata.
|
||||
*
|
||||
* @return array An array of folder information arrays.
|
||||
*/
|
||||
public static function getFolderList(): array
|
||||
{
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
return []; // or ["error" => "..."]
|
||||
}
|
||||
|
||||
$folderInfoList = [];
|
||||
|
||||
// Process the "root" folder.
|
||||
$rootMetaFile = self::getMetadataFilePath('root');
|
||||
$rootFileCount = 0;
|
||||
// root
|
||||
$rootMetaFile = self::getMetadataFilePath('root');
|
||||
$rootFileCount = 0;
|
||||
if (file_exists($rootMetaFile)) {
|
||||
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
|
||||
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
|
||||
}
|
||||
$folderInfoList[] = [
|
||||
"folder" => "root",
|
||||
"fileCount" => $rootFileCount,
|
||||
"folder" => "root",
|
||||
"fileCount" => $rootFileCount,
|
||||
"metadataFile" => basename($rootMetaFile)
|
||||
];
|
||||
|
||||
// Recursively scan for subfolders.
|
||||
if (is_dir($baseDir)) {
|
||||
$subfolders = self::getSubfolders($baseDir);
|
||||
} else {
|
||||
$subfolders = [];
|
||||
}
|
||||
|
||||
// For each subfolder, load metadata to get file counts.
|
||||
// subfolders
|
||||
$subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
|
||||
foreach ($subfolders as $folder) {
|
||||
$metaFile = self::getMetadataFilePath($folder);
|
||||
$fileCount = 0;
|
||||
@@ -239,8 +250,8 @@ class FolderModel
|
||||
$fileCount = is_array($metadata) ? count($metadata) : 0;
|
||||
}
|
||||
$folderInfoList[] = [
|
||||
"folder" => $folder,
|
||||
"fileCount" => $fileCount,
|
||||
"folder" => $folder,
|
||||
"fileCount" => $fileCount,
|
||||
"metadataFile" => basename($metaFile)
|
||||
];
|
||||
}
|
||||
@@ -250,136 +261,101 @@ class FolderModel
|
||||
|
||||
/**
|
||||
* Retrieves the share folder record for a given token.
|
||||
*
|
||||
* @param string $token The share folder token.
|
||||
* @return array|null The share folder record, or null if not found.
|
||||
*/
|
||||
public static function getShareFolderRecord(string $token): ?array
|
||||
{
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return null;
|
||||
}
|
||||
if (!file_exists($shareFile)) return null;
|
||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||
return null;
|
||||
}
|
||||
return $shareLinks[$token];
|
||||
return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves shared folder data based on a share token.
|
||||
*
|
||||
* @param string $token The share folder token.
|
||||
* @param string|null $providedPass The provided password (if any).
|
||||
* @param int $page The page number for pagination.
|
||||
* @param int $itemsPerPage The number of files to display per page.
|
||||
* @return array Associative array with keys:
|
||||
* - 'record': the share record,
|
||||
* - 'folder': the shared folder (relative),
|
||||
* - 'realFolderPath': absolute folder path,
|
||||
* - 'files': array of filenames for the current page,
|
||||
* - 'currentPage': current page number,
|
||||
* - 'totalPages': total pages,
|
||||
* or an 'error' key on failure.
|
||||
*/
|
||||
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array
|
||||
{
|
||||
// Load the share folder record.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return ["error" => "Share link not found."];
|
||||
}
|
||||
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
|
||||
|
||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||
return ["error" => "Share link not found."];
|
||||
}
|
||||
$record = $shareLinks[$token];
|
||||
// Check expiration.
|
||||
if (time() > $record['expires']) {
|
||||
|
||||
if (time() > ($record['expires'] ?? 0)) {
|
||||
return ["error" => "This share link has expired."];
|
||||
}
|
||||
// If password protection is enabled and no password is provided, signal that.
|
||||
|
||||
if (!empty($record['password']) && empty($providedPass)) {
|
||||
return ["needs_password" => true];
|
||||
}
|
||||
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
|
||||
return ["error" => "Invalid password."];
|
||||
}
|
||||
// Determine the shared folder.
|
||||
$folder = trim($record['folder'], "/\\ ");
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
return ["error" => "Uploads directory not configured correctly."];
|
||||
}
|
||||
if (!empty($folder) && strtolower($folder) !== 'root') {
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
|
||||
} else {
|
||||
$folder = "root";
|
||||
$folderPath = $baseDir;
|
||||
}
|
||||
$realFolderPath = realpath($folderPath);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
|
||||
|
||||
// Resolve shared folder
|
||||
$folder = trim((string)$record['folder'], "/\\ ");
|
||||
[$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false);
|
||||
if ($err || !is_dir($realFolderPath)) {
|
||||
return ["error" => "Shared folder not found."];
|
||||
}
|
||||
// Scan for files (only files).
|
||||
$allFiles = array_values(array_filter(scandir($realFolderPath), function ($item) use ($realFolderPath) {
|
||||
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item);
|
||||
}));
|
||||
sort($allFiles);
|
||||
$totalFiles = count($allFiles);
|
||||
$totalPages = max(1, ceil($totalFiles / $itemsPerPage));
|
||||
$currentPage = min($page, $totalPages);
|
||||
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
||||
|
||||
// List files (safe names only; skip hidden)
|
||||
$all = @scandir($realFolderPath) ?: [];
|
||||
$allFiles = [];
|
||||
foreach ($all as $it) {
|
||||
if ($it === '.' || $it === '..') continue;
|
||||
if ($it[0] === '.') continue;
|
||||
if (!preg_match(REGEX_FILE_NAME, $it)) continue;
|
||||
if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) {
|
||||
$allFiles[] = $it;
|
||||
}
|
||||
}
|
||||
sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
$totalFiles = count($allFiles);
|
||||
$totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage)));
|
||||
$currentPage = min(max(1, $page), $totalPages);
|
||||
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
||||
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
|
||||
|
||||
return [
|
||||
"record" => $record,
|
||||
"folder" => $folder,
|
||||
"realFolderPath" => $realFolderPath,
|
||||
"files" => $filesOnPage,
|
||||
"currentPage" => $currentPage,
|
||||
"totalPages" => $totalPages
|
||||
"record" => $record,
|
||||
"folder" => $relative,
|
||||
"realFolderPath"=> $realFolderPath,
|
||||
"files" => $filesOnPage,
|
||||
"currentPage" => $currentPage,
|
||||
"totalPages" => $totalPages
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a share link for a folder.
|
||||
*
|
||||
* @param string $folder The folder to share (relative to UPLOAD_DIR).
|
||||
* @param int $expirationSeconds How many seconds until expiry.
|
||||
* @param string $password Optional password.
|
||||
* @param int $allowUpload 0 or 1 whether uploads are allowed.
|
||||
* @return array ["token","expires","link"] on success, or ["error"].
|
||||
*/
|
||||
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
|
||||
{
|
||||
// Validate folder
|
||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
// Validate folder (and ensure it exists)
|
||||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// Token
|
||||
try {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
return ["error" => "Could not generate token."];
|
||||
}
|
||||
|
||||
// Expiry
|
||||
$expires = time() + $expirationSeconds;
|
||||
$expires = time() + max(1, $expirationSeconds);
|
||||
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
// Password hash
|
||||
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
// Load existing
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
$links = file_exists($shareFile)
|
||||
? json_decode(file_get_contents($shareFile), true) ?? []
|
||||
? (json_decode(file_get_contents($shareFile), true) ?? [])
|
||||
: [];
|
||||
|
||||
// Cleanup
|
||||
// cleanup expired
|
||||
$now = time();
|
||||
foreach ($links as $k => $v) {
|
||||
if (!empty($v['expires']) && $v['expires'] < $now) {
|
||||
@@ -387,107 +363,78 @@ class FolderModel
|
||||
}
|
||||
}
|
||||
|
||||
// Add new
|
||||
$links[$token] = [
|
||||
"folder" => $folder,
|
||||
"folder" => $relative,
|
||||
"expires" => $expires,
|
||||
"password" => $hashedPassword,
|
||||
"allowUpload" => $allowUpload
|
||||
"allowUpload" => $allowUpload ? 1 : 0
|
||||
];
|
||||
|
||||
// Save
|
||||
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) {
|
||||
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
return ["error" => "Could not save share link."];
|
||||
}
|
||||
|
||||
// Build URL
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
|
||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||
$baseUrl = $protocol . '://' . rtrim($host, '/');
|
||||
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||||
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
||||
|
||||
return ["token" => $token, "expires" => $expires, "link" => $link];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information for a shared file from a shared folder link.
|
||||
*
|
||||
* @param string $token The share folder token.
|
||||
* @param string $file The requested file name.
|
||||
* @return array An associative array with keys:
|
||||
* - "error": error message, if any,
|
||||
* - "realFilePath": the absolute path to the file,
|
||||
* - "mimeType": the detected MIME type.
|
||||
*/
|
||||
public static function getSharedFileInfo(string $token, string $file): array
|
||||
{
|
||||
// Load the share folder record.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return ["error" => "Share link not found."];
|
||||
}
|
||||
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
|
||||
|
||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||
return ["error" => "Share link not found."];
|
||||
}
|
||||
$record = $shareLinks[$token];
|
||||
|
||||
// Check if the link has expired.
|
||||
if (time() > $record['expires']) {
|
||||
if (time() > ($record['expires'] ?? 0)) {
|
||||
return ["error" => "This share link has expired."];
|
||||
}
|
||||
|
||||
// Determine the shared folder.
|
||||
$folder = trim($record['folder'], "/\\ ");
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
return ["error" => "Uploads directory not configured correctly."];
|
||||
}
|
||||
if (!empty($folder) && strtolower($folder) !== 'root') {
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
|
||||
} else {
|
||||
$folderPath = $baseDir;
|
||||
}
|
||||
$realFolderPath = realpath($folderPath);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
|
||||
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
|
||||
if ($err || !is_dir($realFolderPath)) {
|
||||
return ["error" => "Shared folder not found."];
|
||||
}
|
||||
|
||||
// Sanitize the file name to prevent path traversal.
|
||||
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) {
|
||||
$file = basename(trim($file));
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
return ["error" => "Invalid file name."];
|
||||
}
|
||||
$file = basename($file);
|
||||
|
||||
// Build the full file path.
|
||||
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||||
$realFilePath = realpath($filePath);
|
||||
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
|
||||
$full = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||||
$real = realpath($full);
|
||||
if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) {
|
||||
return ["error" => "File not found."];
|
||||
}
|
||||
|
||||
$mimeType = mime_content_type($realFilePath);
|
||||
return [
|
||||
"realFilePath" => $realFilePath,
|
||||
"mimeType" => $mimeType
|
||||
];
|
||||
$mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream';
|
||||
return ["realFilePath" => $real, "mimeType" => $mime];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles uploading a file to a shared folder.
|
||||
*
|
||||
* @param string $token The share folder token.
|
||||
* @param array $fileUpload The $_FILES['fileToUpload'] array.
|
||||
* @return array An associative array with "success" on success or "error" on failure.
|
||||
*/
|
||||
public static function uploadToSharedFolder(string $token, array $fileUpload): array
|
||||
{
|
||||
// Define maximum file size and allowed extensions.
|
||||
// Max size & allowed extensions (mirror FileModel’s common types)
|
||||
$maxSize = 50 * 1024 * 1024; // 50 MB
|
||||
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv'];
|
||||
$allowedExtensions = [
|
||||
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
|
||||
'mp4','webm','mp3','mkv','csv','json','xml','md'
|
||||
];
|
||||
|
||||
// Load the share folder record.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return ["error" => "Share record not found."];
|
||||
@@ -498,75 +445,50 @@ class FolderModel
|
||||
}
|
||||
$record = $shareLinks[$token];
|
||||
|
||||
// Check expiration.
|
||||
if (time() > $record['expires']) {
|
||||
if (time() > ($record['expires'] ?? 0)) {
|
||||
return ["error" => "This share link has expired."];
|
||||
}
|
||||
|
||||
// Check whether uploads are allowed.
|
||||
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
|
||||
if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) {
|
||||
return ["error" => "File uploads are not allowed for this share."];
|
||||
}
|
||||
|
||||
// Validate file upload presence.
|
||||
if ($fileUpload['error'] !== UPLOAD_ERR_OK) {
|
||||
return ["error" => "File upload error. Code: " . $fileUpload['error']];
|
||||
if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ["error" => "File upload error. Code: " . (int)$fileUpload['error']];
|
||||
}
|
||||
|
||||
if ($fileUpload['size'] > $maxSize) {
|
||||
if (($fileUpload['size'] ?? 0) > $maxSize) {
|
||||
return ["error" => "File size exceeds allowed limit."];
|
||||
}
|
||||
|
||||
$uploadedName = basename($fileUpload['name']);
|
||||
$uploadedName = basename((string)($fileUpload['name'] ?? ''));
|
||||
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExtensions)) {
|
||||
if (!in_array($ext, $allowedExtensions, true)) {
|
||||
return ["error" => "File type not allowed."];
|
||||
}
|
||||
|
||||
// Determine the target folder from the share record.
|
||||
$folderName = trim($record['folder'], "/\\");
|
||||
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (!empty($folderName) && strtolower($folderName) !== 'root') {
|
||||
$targetFolder .= $folderName;
|
||||
}
|
||||
// Resolve target folder
|
||||
[$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// Verify target folder exists.
|
||||
$realTargetFolder = realpath($targetFolder);
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) {
|
||||
return ["error" => "Shared folder not found."];
|
||||
}
|
||||
// New safe filename
|
||||
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||
$newFilename= uniqid('', true) . "_" . $safeBase;
|
||||
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
||||
|
||||
// Generate a new filename (using uniqid and sanitizing the original name).
|
||||
$newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||
$targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename;
|
||||
|
||||
// Move the uploaded file.
|
||||
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
||||
return ["error" => "Failed to move the uploaded file."];
|
||||
}
|
||||
|
||||
// --- Metadata Update ---
|
||||
// Determine metadata file.
|
||||
$metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName;
|
||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||
$metadataFile = META_DIR . $metadataFileName;
|
||||
$metadataCollection = [];
|
||||
if (file_exists($metadataFile)) {
|
||||
$data = file_get_contents($metadataFile);
|
||||
$metadataCollection = json_decode($data, true);
|
||||
if (!is_array($metadataCollection)) {
|
||||
$metadataCollection = [];
|
||||
}
|
||||
}
|
||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||
$uploader = "Outside Share"; // As per your original implementation.
|
||||
// Update metadata with the new file's info.
|
||||
$metadataCollection[$newFilename] = [
|
||||
"uploaded" => $uploadedDate,
|
||||
"uploader" => $uploader
|
||||
// Update metadata (uploaded + modified + uploader)
|
||||
$metadataFile = self::getMetadataFilePath($relative);
|
||||
$meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||
|
||||
$now = date(DATE_TIME_FORMAT);
|
||||
$meta[$newFilename] = [
|
||||
"uploaded" => $now,
|
||||
"modified" => $now,
|
||||
"uploader" => "Outside Share"
|
||||
];
|
||||
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
|
||||
file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
|
||||
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
|
||||
}
|
||||
@@ -574,9 +496,7 @@ class FolderModel
|
||||
public static function getAllShareFolderLinks(): array
|
||||
{
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return [];
|
||||
}
|
||||
if (!file_exists($shareFile)) return [];
|
||||
$links = json_decode(file_get_contents($shareFile), true);
|
||||
return is_array($links) ? $links : [];
|
||||
}
|
||||
@@ -584,15 +504,13 @@ class FolderModel
|
||||
public static function deleteShareFolderLink(string $token): bool
|
||||
{
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return false;
|
||||
}
|
||||
if (!file_exists($shareFile)) return false;
|
||||
|
||||
$links = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($links) || !isset($links[$token])) {
|
||||
return false;
|
||||
}
|
||||
if (!is_array($links) || !isset($links[$token])) return false;
|
||||
|
||||
unset($links[$token]);
|
||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
|
||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
class userModel
|
||||
{
|
||||
/**
|
||||
* Retrieves all users from the users file.
|
||||
*
|
||||
* @return array Returns an array of users.
|
||||
* Retrieve all users (username + role).
|
||||
*/
|
||||
public static function getAllUsers()
|
||||
{
|
||||
@@ -30,67 +28,75 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new user.
|
||||
* Add a user.
|
||||
*
|
||||
* @param string $username The new username.
|
||||
* @param string $password The plain-text password.
|
||||
* @param string $isAdmin "1" if admin; "0" otherwise.
|
||||
* @param bool $setupMode If true, overwrite the users file.
|
||||
* @return array Response containing either an error or a success message.
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $isAdmin "1" or "0"
|
||||
* @param bool $setupMode overwrite file if true
|
||||
*/
|
||||
public static function addUser($username, $password, $isAdmin, $setupMode)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Ensure users.txt exists.
|
||||
// Defense in depth
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
if (!is_string($password) || $password === '') {
|
||||
return ["error" => "Password required"];
|
||||
}
|
||||
$isAdmin = $isAdmin === '1' ? '1' : '0';
|
||||
|
||||
if (!file_exists($usersFile)) {
|
||||
file_put_contents($usersFile, '');
|
||||
@file_put_contents($usersFile, '', LOCK_EX);
|
||||
}
|
||||
|
||||
// Check if username already exists.
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
// Check duplicates
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
foreach ($existingUsers as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if ($username === $parts[0]) {
|
||||
if (isset($parts[0]) && $username === $parts[0]) {
|
||||
return ["error" => "User already exists"];
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the password.
|
||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||
|
||||
// Prepare the new line.
|
||||
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||
|
||||
// If setup mode, overwrite the file; otherwise, append.
|
||||
if ($setupMode) {
|
||||
file_put_contents($usersFile, $newUserLine);
|
||||
if (file_put_contents($usersFile, $newUserLine, LOCK_EX) === false) {
|
||||
return ["error" => "Failed to write users file"];
|
||||
}
|
||||
} else {
|
||||
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
||||
if (file_put_contents($usersFile, $newUserLine, FILE_APPEND | LOCK_EX) === false) {
|
||||
return ["error" => "Failed to write users file"];
|
||||
}
|
||||
}
|
||||
|
||||
return ["success" => "User added successfully"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified user from the users file and updates the userPermissions file.
|
||||
*
|
||||
* @param string $usernameToRemove The username to remove.
|
||||
* @return array An array with either an error message or a success message.
|
||||
* Remove a user and update encrypted userPermissions.json.
|
||||
*/
|
||||
public static function removeUser($usernameToRemove)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
global $encryptionKey;
|
||||
|
||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
return ["error" => "Users file not found"];
|
||||
}
|
||||
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$newUsers = [];
|
||||
$userFound = false;
|
||||
|
||||
// Loop through users; skip (remove) the specified user.
|
||||
foreach ($existingUsers as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) < 3) {
|
||||
@@ -98,7 +104,7 @@ class userModel
|
||||
}
|
||||
if ($parts[0] === $usernameToRemove) {
|
||||
$userFound = true;
|
||||
continue; // Do not add this user to the new array.
|
||||
continue; // skip
|
||||
}
|
||||
$newUsers[] = $line;
|
||||
}
|
||||
@@ -107,17 +113,25 @@ class userModel
|
||||
return ["error" => "User not found"];
|
||||
}
|
||||
|
||||
// Write the updated user list back to the file.
|
||||
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
||||
$newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : '';
|
||||
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
|
||||
return ["error" => "Failed to update users file"];
|
||||
}
|
||||
|
||||
// Update the userPermissions.json file.
|
||||
// Update *encrypted* userPermissions.json consistently
|
||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||
if (file_exists($permissionsFile)) {
|
||||
$permissionsJson = file_get_contents($permissionsFile);
|
||||
$permissionsArray = json_decode($permissionsJson, true);
|
||||
if (is_array($permissionsArray) && isset($permissionsArray[$usernameToRemove])) {
|
||||
unset($permissionsArray[$usernameToRemove]);
|
||||
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
|
||||
$raw = file_get_contents($permissionsFile);
|
||||
$decrypted = decryptData($raw, $encryptionKey);
|
||||
$permissionsArray = $decrypted !== false
|
||||
? json_decode($decrypted, true)
|
||||
: (json_decode($raw, true) ?: []); // tolerate legacy plaintext
|
||||
|
||||
if (is_array($permissionsArray)) {
|
||||
unset($permissionsArray[strtolower($usernameToRemove)]);
|
||||
$plain = json_encode($permissionsArray, JSON_PRETTY_PRINT);
|
||||
$enc = encryptData($plain, $encryptionKey);
|
||||
file_put_contents($permissionsFile, $enc, LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,11 +139,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves permissions from the userPermissions.json file.
|
||||
* If the current user is an admin, returns all permissions.
|
||||
* Otherwise, returns only the permissions for the current user.
|
||||
*
|
||||
* @return array|object Returns an associative array of permissions or an empty object if none are found.
|
||||
* Get permissions for current user (or all, if admin).
|
||||
*/
|
||||
public static function getUserPermissions()
|
||||
{
|
||||
@@ -137,28 +147,24 @@ class userModel
|
||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||
$permissionsArray = [];
|
||||
|
||||
// Load permissions if the file exists.
|
||||
if (file_exists($permissionsFile)) {
|
||||
$content = file_get_contents($permissionsFile);
|
||||
// Attempt to decrypt the content.
|
||||
$decryptedContent = decryptData($content, $encryptionKey);
|
||||
if ($decryptedContent === false) {
|
||||
// If decryption fails, assume the content is plain JSON.
|
||||
$decrypted = decryptData($content, $encryptionKey);
|
||||
if ($decrypted === false) {
|
||||
// tolerate legacy plaintext
|
||||
$permissionsArray = json_decode($content, true);
|
||||
} else {
|
||||
$permissionsArray = json_decode($decryptedContent, true);
|
||||
$permissionsArray = json_decode($decrypted, true);
|
||||
}
|
||||
if (!is_array($permissionsArray)) {
|
||||
$permissionsArray = [];
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is an admin, return all permissions.
|
||||
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
|
||||
if (!empty($_SESSION['isAdmin'])) {
|
||||
return $permissionsArray;
|
||||
}
|
||||
|
||||
// Otherwise, return only the permissions for the currently logged-in user.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
foreach ($permissionsArray as $storedUsername => $data) {
|
||||
if (strcasecmp($storedUsername, $username) === 0) {
|
||||
@@ -166,129 +172,103 @@ class userModel
|
||||
}
|
||||
}
|
||||
|
||||
// If no permissions are found, return an empty object.
|
||||
return new stdClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user permissions in the userPermissions.json file.
|
||||
*
|
||||
* @param array $permissions An array of permission updates.
|
||||
* @return array An associative array with a success or error message.
|
||||
* Update permissions (encrypted on disk). Skips admins.
|
||||
*/
|
||||
public static function updateUserPermissions($permissions)
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||
$existingPermissions = [];
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||
$existingPermissions = [];
|
||||
|
||||
// Load existing permissions if available and decrypt.
|
||||
if (file_exists($permissionsFile)) {
|
||||
$encryptedContent = file_get_contents($permissionsFile);
|
||||
$json = decryptData($encryptedContent, $encryptionKey);
|
||||
$existingPermissions = json_decode($json, true);
|
||||
if (!is_array($existingPermissions)) {
|
||||
$existingPermissions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Load user roles from the users file.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$userRoles = [];
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) {
|
||||
// Use lowercase keys for consistency.
|
||||
$userRoles[strtolower($parts[0])] = trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each permission update.
|
||||
foreach ($permissions as $perm) {
|
||||
if (!isset($perm['username'])) {
|
||||
continue;
|
||||
}
|
||||
$username = $perm['username'];
|
||||
// Look up the user's role.
|
||||
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
||||
|
||||
// Skip updating permissions for admin users.
|
||||
if ($role === "1") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update permissions: default any missing value to false.
|
||||
$existingPermissions[strtolower($username)] = [
|
||||
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
||||
'readOnly' => isset($perm['readOnly']) ? (bool)$perm['readOnly'] : false,
|
||||
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
||||
];
|
||||
}
|
||||
|
||||
// Convert the updated permissions array to JSON.
|
||||
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||
// Encrypt the JSON.
|
||||
$encryptedData = encryptData($plainText, $encryptionKey);
|
||||
// Save encrypted permissions back to the file.
|
||||
$result = file_put_contents($permissionsFile, $encryptedData);
|
||||
if ($result === false) {
|
||||
return ["error" => "Failed to save user permissions."];
|
||||
}
|
||||
|
||||
return ["success" => "User permissions updated successfully."];
|
||||
// Load existing (decrypt if needed)
|
||||
if (file_exists($permissionsFile)) {
|
||||
$encryptedContent = file_get_contents($permissionsFile);
|
||||
$json = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($json === false) $json = $encryptedContent; // plain JSON fallback
|
||||
$existingPermissions = json_decode($json, true) ?: [];
|
||||
}
|
||||
|
||||
// Load roles to skip admins
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$userRoles = [];
|
||||
if (file_exists($usersFile)) {
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) {
|
||||
$userRoles[strtolower($parts[0])] = trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$knownKeys = [
|
||||
'folderOnly','readOnly','disableUpload',
|
||||
'bypassOwnership','canShare','canZip','viewOwnOnly'
|
||||
];
|
||||
|
||||
foreach ($permissions as $perm) {
|
||||
if (empty($perm['username'])) continue;
|
||||
$uname = strtolower($perm['username']);
|
||||
$role = $userRoles[$uname] ?? null;
|
||||
if ($role === "1") continue; // skip admins
|
||||
|
||||
$current = $existingPermissions[$uname] ?? [];
|
||||
foreach ($knownKeys as $k) {
|
||||
if (array_key_exists($k, $perm)) {
|
||||
$current[$k] = (bool)$perm[$k];
|
||||
} elseif (!isset($current[$k])) {
|
||||
// default missing keys to false (preserve existing if set)
|
||||
$current[$k] = false;
|
||||
}
|
||||
}
|
||||
$existingPermissions[$uname] = $current;
|
||||
}
|
||||
|
||||
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||
$encrypted = encryptData($plain, $encryptionKey);
|
||||
if (file_put_contents($permissionsFile, $encrypted) === false) {
|
||||
return ["error" => "Failed to save user permissions."];
|
||||
}
|
||||
return ["success" => "User permissions updated successfully."];
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the password for the given user.
|
||||
*
|
||||
* @param string $username The username whose password is to be changed.
|
||||
* @param string $oldPassword The old (current) password.
|
||||
* @param string $newPassword The new password.
|
||||
* @return array An array with either a success or error message.
|
||||
* Change password (preserve TOTP + extra fields).
|
||||
*/
|
||||
public static function changePassword($username, $oldPassword, $newPassword)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
return ["error" => "Users file not found"];
|
||||
}
|
||||
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$userFound = false;
|
||||
$newLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
// Expect at least 3 parts: username, hashed password, and role.
|
||||
if (count($parts) < 3) {
|
||||
$newLines[] = $line;
|
||||
continue;
|
||||
}
|
||||
$storedUser = $parts[0];
|
||||
$storedHash = $parts[1];
|
||||
$storedRole = $parts[2];
|
||||
// Preserve TOTP secret if it exists.
|
||||
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
||||
|
||||
if ($storedUser === $username) {
|
||||
$userFound = true;
|
||||
// Verify the old password.
|
||||
if (!password_verify($oldPassword, $storedHash)) {
|
||||
return ["error" => "Old password is incorrect."];
|
||||
}
|
||||
// Hash the new password.
|
||||
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
|
||||
// Rebuild the line, preserving TOTP secret if it exists.
|
||||
if ($totpSecret !== "") {
|
||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
||||
} else {
|
||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
|
||||
}
|
||||
$parts[1] = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
$newLines[] = implode(':', $parts);
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
@@ -298,148 +278,128 @@ class userModel
|
||||
return ["error" => "User not found."];
|
||||
}
|
||||
|
||||
// Save the updated users file.
|
||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
||||
return ["success" => "Password updated successfully."];
|
||||
} else {
|
||||
$payload = implode(PHP_EOL, $newLines) . PHP_EOL;
|
||||
if (file_put_contents($usersFile, $payload, LOCK_EX) === false) {
|
||||
return ["error" => "Could not update password."];
|
||||
}
|
||||
|
||||
return ["success" => "Password updated successfully."];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled.
|
||||
*
|
||||
* @param string $username The username whose panel settings are being updated.
|
||||
* @param bool $totp_enabled Whether TOTP is enabled.
|
||||
* @return array An array indicating success or failure.
|
||||
* Update panel: if TOTP disabled, clear secret.
|
||||
*/
|
||||
public static function updateUserPanel($username, $totp_enabled)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
return ["error" => "Users file not found"];
|
||||
}
|
||||
|
||||
// If TOTP is disabled, update the file to clear the TOTP secret.
|
||||
if (!$totp_enabled) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$newLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
// Leave lines with fewer than three parts unchanged.
|
||||
if (count($parts) < 3) {
|
||||
$newLines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($parts[0] === $username) {
|
||||
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
|
||||
if (count($parts) >= 4) {
|
||||
$parts[3] = "";
|
||||
} else {
|
||||
while (count($parts) < 4) {
|
||||
$parts[] = "";
|
||||
}
|
||||
$parts[3] = "";
|
||||
$newLines[] = implode(':', $parts);
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||
if ($result === false) {
|
||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) === false) {
|
||||
return ["error" => "Failed to disable TOTP secret"];
|
||||
}
|
||||
return ["success" => "User panel updated: TOTP disabled"];
|
||||
}
|
||||
|
||||
// If TOTP is enabled, do nothing.
|
||||
return ["success" => "User panel updated: TOTP remains enabled"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the TOTP secret for the specified user.
|
||||
*
|
||||
* @param string $username The user for whom TOTP should be disabled.
|
||||
* @return bool True if the secret was cleared; false otherwise.
|
||||
* Clear TOTP secret.
|
||||
*/
|
||||
public static function disableTOTPSecret($username)
|
||||
{
|
||||
global $encryptionKey; // In case it's used in this model context.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
return false;
|
||||
}
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$modified = false;
|
||||
$newLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
// If the line doesn't have at least three parts, leave it unchanged.
|
||||
if (count($parts) < 3) {
|
||||
$newLines[] = $line;
|
||||
continue;
|
||||
}
|
||||
if ($parts[0] === $username) {
|
||||
// If a fourth field exists, clear it; otherwise, append an empty field.
|
||||
if (count($parts) >= 4) {
|
||||
$parts[3] = "";
|
||||
} else {
|
||||
while (count($parts) < 4) {
|
||||
$parts[] = "";
|
||||
}
|
||||
$parts[3] = "";
|
||||
$modified = true;
|
||||
$newLines[] = implode(":", $parts);
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if ($modified) {
|
||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||
return file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) !== false;
|
||||
}
|
||||
return $modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to recover TOTP for a user using the supplied recovery code.
|
||||
*
|
||||
* @param string $userId The user identifier.
|
||||
* @param string $recoveryCode The recovery code provided by the user.
|
||||
* @return array An associative array with keys 'status' and 'message'.
|
||||
* Recover via recovery code.
|
||||
*/
|
||||
public static function recoverTOTP($userId, $recoveryCode)
|
||||
{
|
||||
// --- Rate‑limit recovery attempts ---
|
||||
// Rate limit storage
|
||||
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
||||
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
||||
$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId;
|
||||
$attempts = is_file($attemptsFile) ? (json_decode(@file_get_contents($attemptsFile), true) ?: []) : [];
|
||||
$key = ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|' . $userId;
|
||||
$now = time();
|
||||
|
||||
if (isset($attempts[$key])) {
|
||||
// Prune attempts older than 15 minutes.
|
||||
$attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) {
|
||||
return $ts > $now - 900;
|
||||
});
|
||||
$attempts[$key] = array_values(array_filter($attempts[$key], fn($ts) => $ts > $now - 900));
|
||||
}
|
||||
if (count($attempts[$key] ?? []) >= 5) {
|
||||
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
||||
}
|
||||
|
||||
// --- Load user metadata file ---
|
||||
// User JSON file
|
||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||
if (!file_exists($userFile)) {
|
||||
return ['status' => 'error', 'message' => 'User not found'];
|
||||
}
|
||||
|
||||
// --- Open and lock file ---
|
||||
$fp = fopen($userFile, 'c+');
|
||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||
if ($fp) fclose($fp);
|
||||
return ['status' => 'error', 'message' => 'Server error'];
|
||||
}
|
||||
|
||||
$fileContents = stream_get_contents($fp);
|
||||
$data = json_decode($fileContents, true) ?: [];
|
||||
|
||||
// --- Check recovery code ---
|
||||
if (empty($recoveryCode)) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
@@ -448,19 +408,19 @@ class userModel
|
||||
|
||||
$storedHash = $data['totp_recovery_code'] ?? null;
|
||||
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
||||
// Record failed attempt.
|
||||
// record failed attempt
|
||||
$attempts[$key][] = $now;
|
||||
file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX);
|
||||
@file_put_contents($attemptsFile, json_encode($attempts, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
||||
}
|
||||
|
||||
// --- Invalidate recovery code ---
|
||||
// Invalidate code
|
||||
$data['totp_recovery_code'] = null;
|
||||
rewind($fp);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode($data));
|
||||
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
@@ -469,10 +429,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random recovery code.
|
||||
*
|
||||
* @param int $length Length of the recovery code.
|
||||
* @return string
|
||||
* Generate random recovery code.
|
||||
*/
|
||||
private static function generateRecoveryCode($length = 12)
|
||||
{
|
||||
@@ -486,45 +443,34 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a new TOTP recovery code for the specified user.
|
||||
*
|
||||
* @param string $userId The username of the user.
|
||||
* @return array An associative array with the status and recovery code (if successful).
|
||||
* Save new TOTP recovery code (hash on disk) and return plaintext to caller.
|
||||
*/
|
||||
public static function saveTOTPRecoveryCode($userId)
|
||||
{
|
||||
// Determine the user file path.
|
||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||
|
||||
// Ensure the file exists; if not, create it with default data.
|
||||
if (!file_exists($userFile)) {
|
||||
$defaultData = [];
|
||||
if (file_put_contents($userFile, json_encode($defaultData)) === false) {
|
||||
if (file_put_contents($userFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new recovery code.
|
||||
$recoveryCode = self::generateRecoveryCode();
|
||||
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
||||
|
||||
// Open the file, lock it, and update the totp_recovery_code field.
|
||||
$fp = fopen($userFile, 'c+');
|
||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||
if ($fp) fclose($fp);
|
||||
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
||||
}
|
||||
|
||||
// Read and decode the existing JSON.
|
||||
$contents = stream_get_contents($fp);
|
||||
$data = json_decode($contents, true) ?: [];
|
||||
|
||||
// Update the totp_recovery_code field.
|
||||
$data['totp_recovery_code'] = $recoveryHash;
|
||||
|
||||
// Write the new data.
|
||||
rewind($fp);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode($data)); // Plain JSON in production.
|
||||
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
@@ -533,11 +479,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
|
||||
* then builds and returns a QR code image for the OTPAuth URL.
|
||||
*
|
||||
* @param string $username The username for which to set up TOTP.
|
||||
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
|
||||
* Setup TOTP & build QR PNG.
|
||||
*/
|
||||
public static function setupTOTP($username)
|
||||
{
|
||||
@@ -548,9 +490,9 @@ class userModel
|
||||
return ['error' => 'Users file not found'];
|
||||
}
|
||||
|
||||
// Look for an existing TOTP secret.
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$totpSecret = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||
@@ -559,19 +501,18 @@ class userModel
|
||||
}
|
||||
}
|
||||
|
||||
// Use the TwoFactorAuth library to create a new secret if none found.
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
|
||||
'FileRise', // issuer
|
||||
6, // number of digits
|
||||
30, // period (seconds)
|
||||
\RobThree\Auth\Algorithm::Sha1 // algorithm
|
||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
||||
'FileRise',
|
||||
6,
|
||||
30,
|
||||
\RobThree\Auth\Algorithm::Sha1
|
||||
);
|
||||
|
||||
if (!$totpSecret) {
|
||||
$totpSecret = $tfa->createSecret();
|
||||
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
||||
|
||||
// Update the user’s line with the new encrypted secret.
|
||||
$newLines = [];
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
@@ -589,8 +530,7 @@ class userModel
|
||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||
}
|
||||
|
||||
// Determine the OTPAuth URL.
|
||||
// Try to load a global OTPAuth URL template from admin configuration.
|
||||
// Prefer admin-configured otpauth template if present
|
||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||
$globalOtpauthUrl = "";
|
||||
if (file_exists($adminConfigFile)) {
|
||||
@@ -598,7 +538,7 @@ class userModel
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($decryptedContent !== false) {
|
||||
$config = json_decode($decryptedContent, true);
|
||||
if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) {
|
||||
if (!empty($config['globalOtpauthUrl'])) {
|
||||
$globalOtpauthUrl = $config['globalOtpauthUrl'];
|
||||
}
|
||||
}
|
||||
@@ -606,14 +546,17 @@ class userModel
|
||||
|
||||
if (!empty($globalOtpauthUrl)) {
|
||||
$label = "FileRise:" . $username;
|
||||
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
||||
$otpauthUrl = str_replace(
|
||||
["{label}", "{secret}"],
|
||||
[urlencode($label), $totpSecret],
|
||||
$globalOtpauthUrl
|
||||
);
|
||||
} else {
|
||||
$label = urlencode("FileRise:" . $username);
|
||||
$label = urlencode("FileRise:" . $username);
|
||||
$issuer = urlencode("FileRise");
|
||||
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
||||
}
|
||||
|
||||
// Build the QR code image using the Endroid QR Code Builder.
|
||||
$result = \Endroid\QrCode\Builder\Builder::create()
|
||||
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
||||
->data($otpauthUrl)
|
||||
@@ -626,10 +569,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the decrypted TOTP secret for a given user.
|
||||
*
|
||||
* @param string $username
|
||||
* @return string|null Returns the TOTP secret if found, or null if not.
|
||||
* Get decrypted TOTP secret.
|
||||
*/
|
||||
public static function getTOTPSecret($username)
|
||||
{
|
||||
@@ -638,10 +578,9 @@ class userModel
|
||||
if (!file_exists($usersFile)) {
|
||||
return null;
|
||||
}
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
// Expect at least 4 parts: username, hash, role, and TOTP secret.
|
||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||
return decryptData($parts[3], $encryptionKey);
|
||||
}
|
||||
@@ -650,10 +589,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a user's role from users.txt.
|
||||
*
|
||||
* @param string $username
|
||||
* @return string|null
|
||||
* Get role ('1' admin, '0' user) or null.
|
||||
*/
|
||||
public static function getUserRole($username)
|
||||
{
|
||||
@@ -670,27 +606,30 @@ class userModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single user’s info (admin flag, TOTP status, profile picture).
|
||||
*/
|
||||
public static function getUser(string $username): array
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (! file_exists($usersFile)) {
|
||||
if (!file_exists($usersFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
// split *all* the fields
|
||||
$parts = explode(':', $line);
|
||||
|
||||
if ($parts[0] !== $username) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// determine admin & totp
|
||||
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
|
||||
$totpEnabled = !empty($parts[3]);
|
||||
// profile_picture is the 5th field if present
|
||||
$pic = isset($parts[4]) ? $parts[4] : '';
|
||||
|
||||
|
||||
// Normalize to a leading slash (UI expects /uploads/…)
|
||||
if ($pic !== '' && $pic[0] !== '/') {
|
||||
$pic = '/' . $pic;
|
||||
}
|
||||
|
||||
return [
|
||||
'username' => $parts[0],
|
||||
'isAdmin' => $isAdmin,
|
||||
@@ -698,49 +637,44 @@ class userModel
|
||||
'profile_picture' => $pic,
|
||||
];
|
||||
}
|
||||
|
||||
return []; // user not found
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistently set the profile picture URL for a given user,
|
||||
* storing it in the 5th field so we leave the 4th (TOTP secret) untouched.
|
||||
* Persist profile picture URL as 5th field (keeps TOTP secret intact).
|
||||
*
|
||||
* users.txt format:
|
||||
* username:hash:isAdmin:totp_secret:profile_picture
|
||||
*
|
||||
* @param string $username
|
||||
* @param string $url The public URL (e.g. "/uploads/profile_pics/…")
|
||||
* @return array ['success'=>true] or ['success'=>false,'error'=>'…']
|
||||
* users.txt: username:hash:isAdmin:totp_secret:profile_picture
|
||||
*/
|
||||
public static function setProfilePicture(string $username, string $url): array
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (! file_exists($usersFile)) {
|
||||
if (!file_exists($usersFile)) {
|
||||
return ['success' => false, 'error' => 'Users file not found'];
|
||||
}
|
||||
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES);
|
||||
// Ensure leading slash (consistent with controller response)
|
||||
$url = '/' . ltrim($url, '/');
|
||||
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$out = [];
|
||||
$found = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') { $out[] = $line; continue; }
|
||||
$parts = explode(':', $line);
|
||||
if ($parts[0] === $username) {
|
||||
$found = true;
|
||||
// Ensure we have at least 5 fields
|
||||
while (count($parts) < 5) {
|
||||
$parts[] = '';
|
||||
}
|
||||
// Write profile_picture into the 5th field (index 4)
|
||||
$parts[4] = ltrim($url, '/'); // or $url if leading slash is desired
|
||||
// Re-assemble (this preserves parts[3] completely)
|
||||
$parts[4] = $url;
|
||||
$line = implode(':', $parts);
|
||||
}
|
||||
$out[] = $line;
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
if (!$found) {
|
||||
return ['success' => false, 'error' => 'User not found'];
|
||||
}
|
||||
|
||||
@@ -751,4 +685,4 @@ class userModel
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
118
start.sh
118
start.sh
@@ -1,35 +1,70 @@
|
||||
#!/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
|
||||
: > /var/www/metadata/log/error.log
|
||||
: > /var/www/metadata/log/access.log
|
||||
chown www-data:www-data /var/www/metadata/log/*.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 +72,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 +84,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 +107,22 @@ EOF
|
||||
|
||||
# 6) Override ports if provided
|
||||
if [ -n "${HTTP_PORT:-}" ]; then
|
||||
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
|
||||
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf
|
||||
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf || true
|
||||
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf || true
|
||||
fi
|
||||
if [ -n "${HTTPS_PORT:-}" ]; then
|
||||
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf
|
||||
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf || true
|
||||
fi
|
||||
|
||||
# 7) Set ServerName
|
||||
if [ -n "${SERVER_NAME:-}" ]; then
|
||||
echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
|
||||
# 7) Set ServerName (idempotent)
|
||||
SN="${SERVER_NAME:-FileRise}"
|
||||
if grep -qE '^ServerName\s' /etc/apache2/apache2.conf; then
|
||||
sed -i "s|^ServerName .*|ServerName ${SN}|" /etc/apache2/apache2.conf
|
||||
else
|
||||
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
|
||||
echo "ServerName ${SN}" >> /etc/apache2/apache2.conf
|
||||
fi
|
||||
|
||||
# 8) Prepare dynamic data directories with least privilege
|
||||
for d in uploads users metadata; do
|
||||
tgt="/var/www/${d}"
|
||||
mkdir -p "${tgt}"
|
||||
chown www-data:www-data "${tgt}"
|
||||
chmod 775 "${tgt}"
|
||||
done
|
||||
|
||||
# 9) Initialize persistent files if absent
|
||||
# 8) Initialize persistent files if absent
|
||||
if [ ! -f /var/www/users/users.txt ]; then
|
||||
echo "" > /var/www/users/users.txt
|
||||
chown www-data:www-data /var/www/users/users.txt
|
||||
@@ -108,5 +135,38 @@ 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
|
||||
|
||||
# 9.6) Stream Apache logs to the container console (optional toggle)
|
||||
LOG_STREAM="${LOG_STREAM:-error}"
|
||||
case "${LOG_STREAM,,}" in
|
||||
none) STREAM_ERR=false; STREAM_ACC=false ;;
|
||||
access) STREAM_ERR=false; STREAM_ACC=true ;;
|
||||
both) STREAM_ERR=true; STREAM_ACC=true ;;
|
||||
error|*)STREAM_ERR=true; STREAM_ACC=false ;;
|
||||
esac
|
||||
|
||||
echo "🔥 Starting Apache..."
|
||||
# Stream only the chosen logs; -n0 = don't dump history, -F = follow across rotations/creation
|
||||
[ "${STREAM_ERR}" = "true" ] && tail -n0 -F /var/www/metadata/log/error.log 2>/dev/null &
|
||||
[ "${STREAM_ACC}" = "true" ] && tail -n0 -F /var/www/metadata/log/access.log 2>/dev/null &
|
||||
exec apachectl -D FOREGROUND
|
||||
Reference in New Issue
Block a user