Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76f5ed5c96 | ||
|
|
18f588dc24 | ||
|
|
491c686762 | ||
|
|
25303df677 | ||
|
|
ae0d63b86f | ||
|
|
41ade2e205 | ||
|
|
0a9d332d60 | ||
|
|
1983f7705f | ||
|
|
6b2bf0ba70 | ||
|
|
6d9715169c | ||
|
|
0645a3712a | ||
|
|
ebc32ea965 | ||
|
|
078db33458 | ||
|
|
04f5cbe31f | ||
|
|
b5a7d8d559 | ||
|
|
58f8485b02 | ||
|
|
3e1da9c335 | ||
|
|
6bf6206e1c | ||
|
|
f9c60951c9 | ||
|
|
06b3f28df0 | ||
|
|
89f124250c | ||
|
|
66f13fd6a7 | ||
|
|
a81d9cb940 | ||
|
|
13b8871200 | ||
|
|
2792c05c1c | ||
|
|
6ccfc88acb | ||
|
|
7f1d59b33a | ||
|
|
e4e8b108d2 | ||
|
|
242661a9c9 | ||
|
|
ca3e2f316c | ||
|
|
6ff4aa5f34 | ||
|
|
1eb54b8e6e | ||
|
|
4a6c424540 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# dockerignore
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
.github/**
|
||||||
|
Dockerfile*
|
||||||
|
resources/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1,2 +1,4 @@
|
|||||||
public/api.html linguist-documentation
|
public/api.html linguist-documentation
|
||||||
public/openapi.json linguist-documentation
|
public/openapi.json linguist-documentation
|
||||||
|
resources/ export-ignore
|
||||||
|
.github/ export-ignore
|
||||||
3
.github/workflows/sync-changelog.yml
vendored
3
.github/workflows/sync-changelog.yml
vendored
@@ -5,6 +5,9 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'CHANGELOG.md'
|
- 'CHANGELOG.md'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
196
CHANGELOG.md
196
CHANGELOG.md
@@ -1,5 +1,197 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 4/27/2025 1.2.7
|
||||||
|
|
||||||
|
- **Select-All** checkbox now correctly toggles all `.file-checkbox` inputs
|
||||||
|
- Updated `toggleAllCheckboxes(masterCheckbox)` to call `updateRowHighlight()` on each row so selections get the `.row-selected` highlight
|
||||||
|
- **Master checkbox sync** in toolbar
|
||||||
|
- Enhanced `updateFileActionButtons()` to set the header checkbox to checked, unchecked, or indeterminate based on how many files are selected
|
||||||
|
- Fixed Pagination controls & Items-per-page dropdown
|
||||||
|
- Fixed `#advancedSearchToggle` in both `renderFileTable()` and `renderGalleryView()`
|
||||||
|
- **Shared folder gallery view logic**
|
||||||
|
- Introduced new `public/js/sharedFolderView.js` containing all DOMContentLoaded wiring, `toggleViewMode()`, gallery rendering, and event listeners
|
||||||
|
- Embedded a non-executing JSON payload in `shareFolder.php`
|
||||||
|
- **`FolderController::shareFolder()` / `shareFolder.php`**
|
||||||
|
- Removed all inline `onclick="…"` attributes and inline `<script>` blocks
|
||||||
|
- Added `<script type="application/json" id="shared-data">…</script>` to export `$token` and `$files`
|
||||||
|
- Added `<script src="/js/sharedFolderView.js" defer></script>` to load the external view logic
|
||||||
|
- **Styling updates**
|
||||||
|
- Added `.toggle-btn` CSS for blue header-style toggle button and applied it in JS
|
||||||
|
- Added `.pagination a:hover { background-color: #0056b3; }` to match button hover
|
||||||
|
- Tweaked `body` padding and `header h1` margins to reduce whitespace above header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 4/26/2025 1.2.6
|
||||||
|
|
||||||
|
**Apache / Dockerfile (CSP)**
|
||||||
|
|
||||||
|
- Enabled Apache’s `mod_headers` in the Dockerfile (`a2enmod headers ssl deflate expires proxy proxy_fcgi rewrite`)
|
||||||
|
- Added a strong `Content-Security-Policy` header in the vhost configs to lock down allowed sources for scripts, styles, fonts, images, and connections
|
||||||
|
|
||||||
|
**index.html & CDN Includes**
|
||||||
|
|
||||||
|
- Applied Subresource Integrity (`integrity` + `crossorigin="anonymous"`) to all static CDN assets (Bootstrap CSS, CodeMirror CSS/JS, Resumable.js, DOMPurify, Fuse.js)
|
||||||
|
- Omitted SRI on Google Fonts & Material Icons links (dynamic per-browser CSS)
|
||||||
|
- Removed all inline `<script>` and `onclick` attributes; now all behaviors live in external JS modules
|
||||||
|
|
||||||
|
**auth.js (Logout Handling)**
|
||||||
|
|
||||||
|
- Moved the logout-on-`?logout=1` snippet from inline HTML into `auth.js`
|
||||||
|
- In `DOMContentLoaded`, attached a `click` listener to `#logoutBtn` that POSTs to `/api/auth/logout.php` and reloads
|
||||||
|
|
||||||
|
**fileActions.js (Modal Button Handlers)**
|
||||||
|
|
||||||
|
- Externalized the cancel/download buttons for single-file and ZIP-download modals by adding `click` listeners in `fileActions.js`
|
||||||
|
- Removed the inline `onclick` attributes from `#cancelDownloadFile` and `#confirmSingleDownloadButton` in the HTML
|
||||||
|
- Ensured all file-action modals (delete, download, extract, copy, move, rename) now use JS event handlers instead of inline code
|
||||||
|
|
||||||
|
**domUtils.js**
|
||||||
|
|
||||||
|
- **Removed** all inline `onclick` and `onchange` attributes from:
|
||||||
|
- `buildSearchAndPaginationControls` (advanced search toggle, prev/next buttons, items-per-page selector)
|
||||||
|
- `buildFileTableHeader` (select-all checkbox)
|
||||||
|
- `buildFileTableRow` (download, edit, preview, rename buttons)
|
||||||
|
- **Retained** all original logic (file-type icon detection, shift-select, debounce, custom confirm modal, etc.)
|
||||||
|
|
||||||
|
**fileListView.js**
|
||||||
|
|
||||||
|
- **Stopped** generating inline `onclick` handlers in both table and gallery views.
|
||||||
|
- **Added** `data-` attributes on actionable elements:
|
||||||
|
- `data-download-name`, `data-download-folder`
|
||||||
|
- `data-edit-name`, `data-edit-folder`
|
||||||
|
- `data-rename-name`, `data-rename-folder`
|
||||||
|
- `data-preview-url`, `data-preview-name`
|
||||||
|
- IDs on controls: `#advancedSearchToggle`, `#searchInput`, `#prevPageBtn`, `#nextPageBtn`, `#selectAll`, `#itemsPerPageSelect`
|
||||||
|
- **Introduced** `attachListControlListeners()` to bind all events via `addEventListener` immediately after rendering, preserving every interaction without inline code.
|
||||||
|
|
||||||
|
**Additional changes**
|
||||||
|
|
||||||
|
- **Security**: Added `frame-src 'self'` to the Content-Security-Policy header so that the embedded API docs iframe can load from our own origin without relaxing JS restrictions.
|
||||||
|
- **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders.
|
||||||
|
- **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant.
|
||||||
|
- Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready.
|
||||||
|
- Added Content-Security-Policy for `<Files "api.php">` block to allow embedding the ReDoc iframe.
|
||||||
|
- Extracted inline ReDoc init into `public/js/redoc-init.js` and updated `public/api.php` to use deferred `<script>` tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 4/25/2025
|
||||||
|
|
||||||
|
- Switch single‐file download to native `<a>` link (no JS buffering)
|
||||||
|
- Keep spinner modal during ZIP creation and download blob on POST response
|
||||||
|
- Replace text toggle with a single button showing sun/moon icons and hover tooltip
|
||||||
|
|
||||||
|
## Changes 4/24/2025 1.2.5
|
||||||
|
|
||||||
|
- Enhance README and wiki with expanded installation instructions
|
||||||
|
- Adjusted Dockerfile’s Apache vhost to:
|
||||||
|
- Alias `/uploads/` to `/var/www/uploads/` with PHP engine disabled and directory indexes off
|
||||||
|
- Disable HTTP TRACE and tune keep-alive (On, max 100 requests, 5s timeout) and server Timeout (60s)
|
||||||
|
- Add security headers (`X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`, `Referrer-Policy`)
|
||||||
|
- Enable `mod_deflate` compression for HTML, plain text, CSS, JS and JSON
|
||||||
|
- Configure `mod_expires` caching for images (1 month), CSS (1 week) and JS (3 hour)
|
||||||
|
- Deny access to hidden files (dot-files)
|
||||||
|
~~- Add access control in public/.htaccess for api.html & openapi.json; update Nginx example in wiki~~
|
||||||
|
- Remove obsolete folders from repo root
|
||||||
|
- Embed API documentation (`api.php`) directly in the FileRise UI as a full-screen modal
|
||||||
|
- Introduced `openApiModalBtn` in the user panel to launch the API modal
|
||||||
|
- Added `#apiModal` container with a same-origin `<iframe src="api.php">` so session cookies authenticate automatically
|
||||||
|
- Close control uses the existing `.editor-close-btn` for consistent styling and hover effects
|
||||||
|
|
||||||
|
- public/api.html has been replaced by the new api.php wrapper
|
||||||
|
- **`public/api.php`**
|
||||||
|
- Single PHP endpoint for both UI and spec
|
||||||
|
- Enforces `$_SESSION['authenticated']`
|
||||||
|
- Renders the Redoc API docs when accessed normally
|
||||||
|
- Streams the JSON spec from `openapi.json.dist` when called as `api.php?spec=1`
|
||||||
|
- Redirects unauthenticated users to `index.html?redirect=/api.php`
|
||||||
|
- **Moved** `public/openapi.json` → `openapi.json.dist` (moved outside of `public/`) to prevent direct static access
|
||||||
|
- **Dockerfile**: enabled required Apache modules for rewrite, security headers, proxying, caching and compression:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changes 4/23/2025 1.2.4
|
||||||
|
|
||||||
|
**AuthModel**
|
||||||
|
|
||||||
|
- **Added** `validateRememberToken(string $token): ?array`
|
||||||
|
- Reads and decrypts `persistent_tokens.json`
|
||||||
|
- Verifies token exists and hasn’t expired
|
||||||
|
- Returns stored payload (`username`, `expiry`, `isAdmin`, etc.) or `null` if invalid
|
||||||
|
|
||||||
|
**authController (checkAuth)**
|
||||||
|
|
||||||
|
- **Enhanced** “remember-me” re-login path at top of `checkAuth()`
|
||||||
|
- Calls `AuthModel::validateRememberToken()` when session is missing but `remember_me_token` cookie present
|
||||||
|
- Repopulates `$_SESSION['authenticated']`, `username`, `isAdmin`, `folderOnly`, `readOnly`, `disableUpload` from payload
|
||||||
|
- Regenerates session ID and CSRF token, then immediately returns JSON and exits
|
||||||
|
|
||||||
|
- **Updated** `userController.php`
|
||||||
|
- Fixed totp isAdmin when session is missing but `remember_me_token` cookie present
|
||||||
|
|
||||||
|
- **loadCsrfToken()**
|
||||||
|
- Now reads `X-CSRF-Token` response header first, falls back to JSON `csrf_token` if header absent
|
||||||
|
- Updates `window.csrfToken`, `window.SHARE_URL`, and `<meta>` tags with the new values
|
||||||
|
- **fetchWithCsrf(url, options)**
|
||||||
|
- Sends `credentials: 'include'` and current `X-CSRF-Token` on every request
|
||||||
|
- Handles “soft-failure” JSON (`{ csrf_expired: true, csrf_token }`): updates token and retries once without a 403 in DevTools
|
||||||
|
- On HTTP 403 fallback: reads new token from header or `/api/auth/token.php`, updates token, and retries once
|
||||||
|
|
||||||
|
- **start.sh**
|
||||||
|
- Session directory setup
|
||||||
|
|
||||||
|
- Always sends `credentials: 'include'` and `X-CSRF-Token: window.csrfToken` s
|
||||||
|
- On HTTP 403, automatically fetches a fresh CSRF token (from the response header or `/api/auth/token.php`) and retries the request once
|
||||||
|
- Always returns the real `Response` object (no more “clone.json” on every 200)
|
||||||
|
- Now calls `fetchWithCsrf('/api/auth/token.php')` to guarantee a fresh token
|
||||||
|
- Checks `res.ok`, then parses JSON to extract `csrf_token` and `share_url`
|
||||||
|
- Updates both `window.csrfToken` and the `<meta name="csrf-token">` & `<meta name="share-url">` tags
|
||||||
|
- Removed Old CSRF logic that cloned every successful response and parsed its JSON body
|
||||||
|
- Removed Any “soft-failure” JSON peek on non-403 responses
|
||||||
|
- Add missing permissions in `UserModel.php` for TOTP login.
|
||||||
|
- **Prevent XSS in breadcrumbs**
|
||||||
|
- Replaced `innerHTML` calls in `fileListTitle` with a new `updateBreadcrumbTitle()` helper that uses `textContent` + `DocumentFragment`.
|
||||||
|
- Introduced `renderBreadcrumbFragment()` to build each breadcrumb segment as a `<span class="breadcrumb-link" data-folder="…">` node.
|
||||||
|
- Added `setupBreadcrumbDelegation()` to handle clicks via event delegation on the container, eliminating per-element listeners.
|
||||||
|
- Removed any raw HTML concatenation to satisfy CodeQL and ensure all breadcrumb text is safely escaped.
|
||||||
|
|
||||||
|
## Changes 4/22/2025 v1.2.3
|
||||||
|
|
||||||
|
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
|
||||||
|
- New `PUID` and `PGID` config options in the Unraid Community Apps template
|
||||||
|
- Dockerfile:
|
||||||
|
- startup (`start.sh`) now runs as root to write `/etc/php` & `/etc/apache2` configs
|
||||||
|
- `www‑data` user is remapped at build‑time to the supplied `PUID:PGID`, then Apache drops privileges to that user
|
||||||
|
- Unraid template: removed recommendation to use `--user`; replaced with `PUID`, `PGID`, and `Container Port` variables
|
||||||
|
- “Permission denied” errors when forcing `--user 99:100` on Unraid by ensuring startup runs as root
|
||||||
|
- Dockerfile silence group issue
|
||||||
|
- `enableWebDAV` toggle in Admin Panel (default: disabled)
|
||||||
|
- **Admin Panel enhancements**
|
||||||
|
- New `enableWebDAV` boolean setting
|
||||||
|
- New `sharedMaxUploadSize` numeric setting (bytes)
|
||||||
|
- **Shared Folder upload size**
|
||||||
|
- `sharedMaxUploadSize` is now enforced in `FolderModel::uploadToSharedFolder`
|
||||||
|
- Upload form header on shared‑folder page dynamically shows “(X MB max size)”
|
||||||
|
- **API updates**
|
||||||
|
- `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize`
|
||||||
|
- Updated `AdminModel` & `AdminController` to persist and validate new settings
|
||||||
|
- Enhanced `shareFolder()` view to pull from admin config and format the max‑upload‑size label
|
||||||
|
- Restored the MIT license copyright line that was inadvertently removed.
|
||||||
|
- Move .htaccess to public folder this was mistake since API refactor.
|
||||||
|
- gitattributes to ignore resources/ & .github/ on export
|
||||||
|
- Hardened `Dockerfile` permissions: all code files owned by `root:www-data` (dirs `755`, files `644`), only `uploads/`, `users/` and `metadata/` are writable by `www-data` (`775`)
|
||||||
|
- `.dockerignore` entry to exclude the `.github` directory from build context
|
||||||
|
- `start.sh`:
|
||||||
|
- Creates and secures `metadata/log` for Apache logs
|
||||||
|
- Dynamically creates and sets permissions on `uploads`, `users`, and `metadata` directories at startup
|
||||||
|
- Apache VirtualHost updated to redirect `ErrorLog` and `CustomLog` into `/var/www/metadata/log`
|
||||||
|
- docker: remove symlink add alias for uploads folder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 4/21/2025 v1.2.2
|
## Changes 4/21/2025 v1.2.2
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -40,7 +232,7 @@
|
|||||||
Refactored to:
|
Refactored to:
|
||||||
1. Fetch CSRF
|
1. Fetch CSRF
|
||||||
2. POST credentials to `/api/auth/auth.php`
|
2. POST credentials to `/api/auth/auth.php`
|
||||||
3. On `totp_required`, re‑fetch CSRF *again* before calling `openTOTPLoginModal()`
|
3. On `totp_required`, re‑fetch CSRF again before calling `openTOTPLoginModal()`
|
||||||
4. Handle full logins vs. TOTP flows cleanly.
|
4. Handle full logins vs. TOTP flows cleanly.
|
||||||
|
|
||||||
- **TOTP handlers update**
|
- **TOTP handlers update**
|
||||||
@@ -986,7 +1178,7 @@ The enhancements extend the existing drag-and-drop functionality by adding a hea
|
|||||||
- Adjusted file preview and icon styling for better alignment.
|
- Adjusted file preview and icon styling for better alignment.
|
||||||
- Centered the header and optimized the layout for a clean, modern appearance.
|
- Centered the header and optimized the layout for a clean, modern appearance.
|
||||||
|
|
||||||
*This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.*
|
This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
136
Dockerfile
136
Dockerfile
@@ -6,12 +6,9 @@
|
|||||||
FROM ubuntu:24.04 AS appsource
|
FROM ubuntu:24.04 AS appsource
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends ca-certificates && \
|
apt-get install -y --no-install-recommends ca-certificates && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/* # clean up apt cache
|
||||||
|
|
||||||
# prepare the folder and remove Apache’s default index
|
|
||||||
RUN mkdir -p /var/www && rm -f /var/www/html/index.html
|
RUN mkdir -p /var/www && rm -f /var/www/html/index.html
|
||||||
|
|
||||||
# **Copy the FileRise source** (where your composer.json lives)
|
|
||||||
COPY . /var/www
|
COPY . /var/www
|
||||||
|
|
||||||
#############################
|
#############################
|
||||||
@@ -19,88 +16,123 @@ COPY . /var/www
|
|||||||
#############################
|
#############################
|
||||||
FROM composer:2 AS composer
|
FROM composer:2 AS composer
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# **Copy composer files from the source** and install
|
|
||||||
COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./
|
COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./
|
||||||
RUN composer install --no-dev --optimize-autoloader
|
RUN composer install --no-dev --optimize-autoloader # production-ready autoloader
|
||||||
|
|
||||||
#############################
|
#############################
|
||||||
# Final Stage – runtime image
|
# Final Stage – runtime image
|
||||||
#############################
|
#############################
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
LABEL by=error311
|
LABEL by=error311
|
||||||
|
|
||||||
# Set basic environment variables
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
HOME=/root \
|
HOME=/root \
|
||||||
LC_ALL=C.UTF-8 \
|
LC_ALL=C.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 TERM=xterm \
|
||||||
LANG=en_US.UTF-8 \
|
UPLOAD_MAX_FILESIZE=5G POST_MAX_SIZE=5G TOTAL_UPLOAD_SIZE=5G \
|
||||||
LANGUAGE=en_US.UTF-8 \
|
PERSISTENT_TOKENS_KEY=default_please_change_this_key \
|
||||||
TERM=xterm \
|
PUID=99 PGID=100
|
||||||
UPLOAD_MAX_FILESIZE=5G \
|
|
||||||
POST_MAX_SIZE=5G \
|
|
||||||
TOTAL_UPLOAD_SIZE=5G \
|
|
||||||
PERSISTENT_TOKENS_KEY=default_please_change_this_key
|
|
||||||
|
|
||||||
ARG PUID=99
|
|
||||||
ARG PGID=100
|
|
||||||
|
|
||||||
# Install Apache, PHP, and required extensions
|
# Install Apache, PHP, and required extensions
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get upgrade -y && \
|
apt-get upgrade -y && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
apache2 \
|
apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
|
||||||
php \
|
ca-certificates curl git openssl && \
|
||||||
php-json \
|
apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
|
||||||
php-curl \
|
|
||||||
php-zip \
|
|
||||||
php-mbstring \
|
|
||||||
php-gd \
|
|
||||||
php-xml \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
openssl && \
|
|
||||||
apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Fix www-data UID/GID
|
# Remap www-data to the PUID/PGID provided for safe bind mounts
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u ${PUID} www-data || true; fi; \
|
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u "${PUID}" www-data; fi; \
|
||||||
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g ${PGID} www-data || true; fi; \
|
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g "${PGID}" www-data 2>/dev/null || true; fi; \
|
||||||
usermod -g ${PGID} www-data
|
usermod -g "${PGID}" www-data
|
||||||
|
|
||||||
# Copy application code and vendor directory
|
# Copy config, code, and vendor
|
||||||
COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
|
COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
|
||||||
COPY --from=appsource /var/www /var/www
|
COPY --from=appsource /var/www /var/www
|
||||||
COPY --from=composer /app/vendor /var/www/vendor
|
COPY --from=composer /app/vendor /var/www/vendor
|
||||||
|
|
||||||
# Fix ownership & permissions
|
# Secure permissions: code read-only, only data dirs writable
|
||||||
RUN chown -R www-data:www-data /var/www && chmod -R 775 /var/www
|
RUN chown -R root:www-data /var/www && \
|
||||||
|
find /var/www -type d -exec chmod 755 {} \; && \
|
||||||
|
find /var/www -type f -exec chmod 644 {} \; && \
|
||||||
|
mkdir -p /var/www/public/uploads /var/www/users /var/www/metadata && \
|
||||||
|
chown -R www-data:www-data /var/www/public/uploads /var/www/users /var/www/metadata && \
|
||||||
|
chmod -R 775 /var/www/public/uploads /var/www/users /var/www/metadata # writable upload areas
|
||||||
|
|
||||||
# Create a symlink for uploads folder in public directory.
|
# Apache site configuration
|
||||||
RUN cd /var/www/public && ln -s ../uploads uploads
|
|
||||||
|
|
||||||
# Configure Apache
|
|
||||||
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
|
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
|
# Global settings
|
||||||
|
TraceEnable off
|
||||||
|
KeepAlive On
|
||||||
|
MaxKeepAliveRequests 100
|
||||||
|
KeepAliveTimeout 5
|
||||||
|
Timeout 60
|
||||||
|
|
||||||
ServerAdmin webmaster@localhost
|
ServerAdmin webmaster@localhost
|
||||||
DocumentRoot /var/www/public
|
DocumentRoot /var/www/public
|
||||||
|
|
||||||
|
# Security headers for all responses
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-XSS-Protection "1; mode=block"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive on
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType text/css "access plus 1 week"
|
||||||
|
ExpiresByType application/javascript "access plus 3 hour"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Protect uploads directory
|
||||||
|
Alias /uploads/ /var/www/uploads/
|
||||||
|
<Directory "/var/www/uploads/">
|
||||||
|
Options -Indexes
|
||||||
|
AllowOverride None
|
||||||
|
<IfModule mod_php7.c>
|
||||||
|
php_flag engine off
|
||||||
|
</IfModule>
|
||||||
|
<IfModule mod_php.c>
|
||||||
|
php_flag engine off
|
||||||
|
</IfModule>
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Public directory
|
||||||
<Directory "/var/www/public">
|
<Directory "/var/www/public">
|
||||||
AllowOverride All
|
AllowOverride All
|
||||||
Require all granted
|
Require all granted
|
||||||
DirectoryIndex index.php index.html
|
DirectoryIndex index.html index.php
|
||||||
</Directory>
|
</Directory>
|
||||||
ErrorLog /var/log/apache2/error.log
|
|
||||||
CustomLog /var/log/apache2/access.log combined
|
# Deny access to hidden files
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<Files "api.php">
|
||||||
|
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
ErrorLog /var/www/metadata/log/error.log
|
||||||
|
CustomLog /var/www/metadata/log/access.log combined
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Enable the rewrite and headers modules
|
# Enable required modules
|
||||||
RUN a2enmod rewrite headers
|
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
|
||||||
|
|
||||||
# Expose ports and set up start script
|
|
||||||
EXPOSE 80 443
|
EXPOSE 80 443
|
||||||
COPY start.sh /usr/local/bin/start.sh
|
COPY start.sh /usr/local/bin/start.sh
|
||||||
RUN chmod +x /usr/local/bin/start.sh
|
RUN chmod +x /usr/local/bin/start.sh
|
||||||
|
|||||||
1
LICENSE
1
LICENSE
@@ -1,5 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 SeNS
|
||||||
Copyright (c) 2025 FileRise
|
Copyright (c) 2025 FileRise
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -1,7 +1,7 @@
|
|||||||
# FileRise
|
# FileRise
|
||||||
|
|
||||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||||
Upload, organize, and share files 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.
|
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.
|
||||||
|
|
||||||
**4/3/2025 Video demo:**
|
**4/3/2025 Video demo:**
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Upload, organize, and share files through a sleek web interface. **FileRise** is
|
|||||||
|
|
||||||
- 🗃️ **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:** 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.
|
||||||
|
|
||||||
- 🔌 **WebDAV Support:** Mount FileRise as a network drive or connect via any WebDAV client. Supports standard file operations (upload/download/rename/delete) and direct `curl`/CLI access for scripting and automation. FolderOnly users are restricted to their personal folder, while admins and unrestricted users have full access. Compatible with Cyberduck, WinSCP, native OS drive mounts, and more.
|
- 🔌 **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.
|
||||||
|
|
||||||
- 📚 **API Documentation:** Fully auto‑generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
- 📚 **API Documentation:** Fully auto‑generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
||||||
|
|
||||||
@@ -108,16 +108,16 @@ FileRise will be accessible at `http://localhost:8080` (or your server’s IP).
|
|||||||
|
|
||||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
||||||
|
|
||||||
- **Requirements:** PHP 8.1 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
|
- **Requirements:** 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).
|
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
git clone https://github.com/error311/FileRise.git
|
git clone https://github.com/error311/FileRise.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Place the files into your web server’s directory (e.g., `/var/www/html/filerise`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
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:** If you plan to use OIDC (SSO login), 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.) If you skip this, FileRise will still work, but OIDC login won’t be available.
|
- **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):
|
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ Now navigate to the FileRise URL in your browser. On first load, you’ll be pro
|
|||||||
|
|
||||||
## Quick‑start: Mount via WebDAV
|
## Quick‑start: Mount via WebDAV
|
||||||
|
|
||||||
Once FileRise is running, you can mount it like any other network drive:
|
Once FileRise is running, you must enable WebDAV in admin panel to access it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux (GVFS/GIO)
|
# Linux (GVFS/GIO)
|
||||||
@@ -232,19 +232,25 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
|||||||
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
|
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
|
||||||
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
|
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
|
||||||
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
|
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
|
||||||
- **[sabre/dav"](https://github.com/sabre-io/dav)** (^4.4)
|
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
|
||||||
|
|
||||||
### Client-Side Libraries
|
### Client-Side Libraries
|
||||||
|
|
||||||
- **Google Fonts** – [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
|
- **Google Fonts** – [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
|
||||||
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
|
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
|
||||||
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) – For code editing functionality.
|
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) – For code editing functionality.
|
||||||
- **[Resumable.js](http://www.resumablejs.com/)** (v1.1.0) – For file uploads.
|
- **[Resumable.js](https://github.com/23/resumable.js/)** (v1.1.0) – For file uploads.
|
||||||
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) – For sanitizing HTML.
|
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) – For sanitizing HTML.
|
||||||
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) – For indexed, fuzzy searching.
|
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) – For indexed, fuzzy searching.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## 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!
|
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!
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ upload_tmp_dir=/tmp
|
|||||||
session.gc_maxlifetime=1440
|
session.gc_maxlifetime=1440
|
||||||
session.gc_probability=1
|
session.gc_probability=1
|
||||||
session.gc_divisor=100
|
session.gc_divisor=100
|
||||||
|
session.save_path = "/var/www/sessions"
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; Error Handling / Logging
|
; Error Handling / Logging
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<!-- public/api.html -->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
||||||
<title>FileRise API Docs</title>
|
|
||||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX" crossorigin="anonymous"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<redoc spec-url="openapi.json"></redoc>
|
|
||||||
<div id="redoc-container"></div>
|
|
||||||
<script>
|
|
||||||
// If the <redoc> tag didn’t render, fall back to init()
|
|
||||||
if (!customElements.get('redoc')) {
|
|
||||||
Redoc.init('openapi.json', {}, document.getElementById('redoc-container'));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
31
public/api.php
Normal file
31
public/api.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
// public/api.php
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
header('Location: /index.html?redirect=/api.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['spec'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
readfile(__DIR__ . '/../openapi.json.dist');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>FileRise API Docs</title>
|
||||||
|
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||||
|
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script defer src="/js/redoc-init.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<redoc spec-url="api.php?spec=1"></redoc>
|
||||||
|
<div id="redoc-container"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -80,6 +80,9 @@ body.dark-mode .header-container {
|
|||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
#darkModeIcon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.header-logo {
|
.header-logo {
|
||||||
max-height: 50px;
|
max-height: 50px;
|
||||||
|
|||||||
@@ -5,13 +5,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title data-i18n-key="title">FileRise</title>
|
<title data-i18n-key="title">FileRise</title>
|
||||||
<script>
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (params.get('logout') === '1') {
|
|
||||||
localStorage.removeItem("username");
|
|
||||||
localStorage.removeItem("userTOTPEnabled");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||||
<meta name="csrf-token" content="">
|
<meta name="csrf-token" content="">
|
||||||
@@ -20,9 +13,12 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
|
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
|
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
|
||||||
|
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
|
||||||
|
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
||||||
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
@@ -41,9 +37,9 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
|
||||||
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
|
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
|
||||||
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
|
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<link rel="stylesheet" href="css/styles.css" />
|
<link rel="stylesheet" href="css/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -78,16 +74,16 @@
|
|||||||
stroke: white;
|
stroke: white;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
stroke: #1565C0;
|
stroke: #1565C0;
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
fill: #FFFFFF;
|
fill: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
fill: #1565C0;
|
fill: #1565C0;
|
||||||
}
|
}
|
||||||
@@ -159,7 +155,11 @@
|
|||||||
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
||||||
<i class="material-icons">person_remove</i>
|
<i class="material-icons">person_remove</i>
|
||||||
</button>
|
</button>
|
||||||
<button id="darkModeToggle" class="dark-mode-toggle" data-i18n-key="dark_mode_toggle">Dark Mode</button>
|
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
|
||||||
|
<span class="material-icons" id="darkModeIcon">
|
||||||
|
dark_mode
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +200,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Basic HTTP Login Option -->
|
<!-- Basic HTTP Login Option -->
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-3">
|
||||||
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic HTTP
|
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic
|
||||||
|
HTTP
|
||||||
Login</a>
|
Login</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,10 +285,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
</button>
|
</button>
|
||||||
<button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder">
|
<button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder">
|
||||||
<i class="material-icons">delete</i>
|
<i class="material-icons">delete</i>
|
||||||
</button>
|
</button>
|
||||||
@@ -391,36 +392,43 @@
|
|||||||
</div> <!-- end mainColumn -->
|
</div> <!-- end mainColumn -->
|
||||||
</div> <!-- end main-wrapper -->
|
</div> <!-- end main-wrapper -->
|
||||||
|
|
||||||
<!-- Download Progress Modal -->
|
<!-- Download Progress Modal -->
|
||||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||||
<!-- Material icon spinner with a dedicated class -->
|
<h4 id="downloadProgressTitle" data-i18n-key="preparing_download">
|
||||||
<span class="material-icons download-spinner">autorenew</span>
|
Preparing your download...
|
||||||
<p data-i18n-key="preparing_download">Preparing your download...</p>
|
</h4>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Single File Download Modal -->
|
<!-- spinner -->
|
||||||
<div id="downloadFileModal" class="modal" style="display: none;">
|
<span class="material-icons download-spinner">autorenew</span>
|
||||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
|
||||||
<h4 data-i18n-key="download_file">Download File</h4>
|
<!-- these were missing -->
|
||||||
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p>
|
<progress id="downloadProgressBar" value="0" max="100" style="width:100%; height:1.5em; display:none;"></progress>
|
||||||
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename" placeholder="Filename" />
|
<p>
|
||||||
<div style="margin-top: 15px; text-align: right;">
|
<span id="downloadProgressPercent" style="display:none;">0%</span>
|
||||||
<button id="cancelDownloadFile" class="btn btn-secondary"
|
</p>
|
||||||
onclick="document.getElementById('downloadFileModal').style.display = 'none';"
|
</div>
|
||||||
data-i18n-key="cancel">Cancel</button>
|
</div>
|
||||||
<button id="confirmSingleDownloadButton" class="btn btn-primary"
|
|
||||||
onclick="confirmSingleDownload()"
|
<!-- Single File Download Modal -->
|
||||||
data-i18n-key="download">Download</button>
|
<div id="downloadFileModal" class="modal" style="display: none;">
|
||||||
|
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||||
|
<h4 data-i18n-key="download_file">Download File</h4>
|
||||||
|
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p>
|
||||||
|
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename"
|
||||||
|
placeholder="Filename" />
|
||||||
|
<div style="margin-top: 15px; text-align: right;">
|
||||||
|
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||||
<span id="closeChangePasswordModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">×</span>
|
<span id="closeChangePasswordModal"
|
||||||
|
style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">×</span>
|
||||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||||
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||||
|
|||||||
@@ -44,6 +44,55 @@ function showToast(msgKey) {
|
|||||||
}
|
}
|
||||||
window.showToast = showToast;
|
window.showToast = showToast;
|
||||||
|
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {object} options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
export async function fetchWithCsrf(url, options = {}) {
|
||||||
|
// 1) Merge in credentials + header
|
||||||
|
options = {
|
||||||
|
credentials: 'include',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
options.headers = {
|
||||||
|
...(options.headers || {}),
|
||||||
|
'X-CSRF-Token': window.csrfToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) First attempt
|
||||||
|
let res = await originalFetch(url, options);
|
||||||
|
|
||||||
|
// 3) If we got a 403, try to refresh token & retry
|
||||||
|
if (res.status === 403) {
|
||||||
|
// 3a) See if the server gave us a new token header
|
||||||
|
let newToken = res.headers.get('X-CSRF-Token');
|
||||||
|
// 3b) Otherwise fall back to the /api/auth/token endpoint
|
||||||
|
if (!newToken) {
|
||||||
|
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
|
||||||
|
if (tokRes.ok) {
|
||||||
|
const body = await tokRes.json();
|
||||||
|
newToken = body.csrf_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newToken) {
|
||||||
|
// 3c) Update global + meta
|
||||||
|
window.csrfToken = newToken;
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (meta) meta.content = newToken;
|
||||||
|
|
||||||
|
// 3d) Retry the original request with the new token
|
||||||
|
options.headers['X-CSRF-Token'] = newToken;
|
||||||
|
res = await originalFetch(url, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Return the real Response—no body peeking here!
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
|
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
|
||||||
function openTOTPLoginModal() {
|
function openTOTPLoginModal() {
|
||||||
originalOpenTOTPLoginModal();
|
originalOpenTOTPLoginModal();
|
||||||
@@ -228,6 +277,7 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
}
|
}
|
||||||
window.setupMode = false;
|
window.setupMode = false;
|
||||||
if (data.authenticated) {
|
if (data.authenticated) {
|
||||||
|
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
|
||||||
localStorage.setItem("folderOnly", data.folderOnly);
|
localStorage.setItem("folderOnly", data.folderOnly);
|
||||||
localStorage.setItem("readOnly", data.readOnly);
|
localStorage.setItem("readOnly", data.readOnly);
|
||||||
localStorage.setItem("disableUpload", data.disableUpload);
|
localStorage.setItem("disableUpload", data.disableUpload);
|
||||||
@@ -235,6 +285,10 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
if (typeof data.totp_enabled !== "undefined") {
|
if (typeof data.totp_enabled !== "undefined") {
|
||||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
|
if (data.csrf_token) {
|
||||||
|
window.csrfToken = data.csrf_token;
|
||||||
|
document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
|
||||||
|
}
|
||||||
updateAuthenticatedUI(data);
|
updateAuthenticatedUI(data);
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
@@ -276,11 +330,11 @@ async function submitLogin(data) {
|
|||||||
try {
|
try {
|
||||||
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
|
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
|
||||||
if (perm && typeof perm === "object") {
|
if (perm && typeof perm === "object") {
|
||||||
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
|
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
|
||||||
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
|
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
|
||||||
localStorage.setItem("disableUpload",perm.disableUpload? "true" : "false");
|
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
return window.location.reload();
|
return window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,13 +437,7 @@ function initAuth() {
|
|||||||
submitLogin(formData);
|
submitLogin(formData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.getElementById("logoutBtn").addEventListener("click", function () {
|
|
||||||
fetch("/api/auth/logout.php", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
|
||||||
}).then(() => window.location.reload(true)).catch(() => { });
|
|
||||||
});
|
|
||||||
document.getElementById("addUserBtn").addEventListener("click", function () {
|
document.getElementById("addUserBtn").addEventListener("click", function () {
|
||||||
resetUserForm();
|
resetUserForm();
|
||||||
toggleVisibility("addUserModal", true);
|
toggleVisibility("addUserModal", true);
|
||||||
@@ -405,10 +453,10 @@ function initAuth() {
|
|||||||
}
|
}
|
||||||
let url = "/api/addUser.php";
|
let url = "/api/addUser.php";
|
||||||
if (window.setupMode) url += "?setup=1";
|
if (window.setupMode) url += "?setup=1";
|
||||||
fetch(url, {
|
fetchWithCsrf(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
|
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -438,10 +486,10 @@ function initAuth() {
|
|||||||
}
|
}
|
||||||
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
|
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
fetch("/api/removeUser.php", {
|
fetchWithCsrf("/api/removeUser.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: usernameToRemove })
|
body: JSON.stringify({ username: usernameToRemove })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -477,10 +525,10 @@ function initAuth() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = { oldPassword, newPassword, confirmPassword };
|
const data = { oldPassword, newPassword, confirmPassword };
|
||||||
fetch("/api/changePassword.php", {
|
fetchWithCsrf("/api/changePassword.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
|
|||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc } from './auth.js';
|
||||||
|
|
||||||
const version = "v1.2.2"; // Update this version string as needed
|
const version = "v1.2.7"; // Update this version string as needed
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
@@ -230,14 +230,39 @@ export function openUserPanel() {
|
|||||||
|
|
||||||
<!-- New API Docs link -->
|
<!-- New API Docs link -->
|
||||||
<div style="margin-bottom: 15px;">
|
<div style="margin-bottom: 15px;">
|
||||||
<a href="api.html" target="_blank" class="btn btn-secondary">
|
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
|
||||||
${t("api_docs") || "API Docs"}
|
${t("api_docs") || "API Docs"}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(userPanelModal);
|
document.body.appendChild(userPanelModal);
|
||||||
|
|
||||||
|
const apiModal = document.createElement("div");
|
||||||
|
apiModal.id = "apiModal";
|
||||||
|
apiModal.style.cssText = `
|
||||||
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// api.php
|
||||||
|
apiModal.innerHTML = `
|
||||||
|
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||||||
|
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||||||
|
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(apiModal);
|
||||||
|
|
||||||
|
document.getElementById("openApiModalBtn").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "flex";
|
||||||
|
});
|
||||||
|
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
// Handlers…
|
// Handlers…
|
||||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
||||||
userPanelModal.style.display = "none";
|
userPanelModal.style.display = "none";
|
||||||
@@ -246,6 +271,7 @@ export function openUserPanel() {
|
|||||||
document.getElementById("changePasswordModal").style.display = "block";
|
document.getElementById("changePasswordModal").style.display = "block";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// TOTP checkbox
|
// TOTP checkbox
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
||||||
@@ -597,6 +623,7 @@ export function openAdminPanel() {
|
|||||||
}
|
}
|
||||||
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
|
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
|
||||||
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||||
const modalContentStyles = `
|
const modalContentStyles = `
|
||||||
@@ -611,6 +638,7 @@ export function openAdminPanel() {
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let adminModal = document.getElementById("adminPanelModal");
|
let adminModal = document.getElementById("adminPanelModal");
|
||||||
|
|
||||||
if (!adminModal) {
|
if (!adminModal) {
|
||||||
@@ -663,6 +691,28 @@ export function openAdminPanel() {
|
|||||||
<label for="disableOIDCLogin">${t("disable_oidc_login")}</label>
|
<label for="disableOIDCLogin">${t("disable_oidc_login")}</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- New WebDAV setting -->
|
||||||
|
<fieldset style="margin-bottom: 15px;">
|
||||||
|
<legend>WebDAV Access</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="checkbox" id="enableWebDAV" />
|
||||||
|
<label for="enableWebDAV">Enable WebDAV</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<!-- End WebDAV setting -->
|
||||||
|
|
||||||
|
<!-- New Shared Max Upload Size setting -->
|
||||||
|
<fieldset style="margin-bottom: 15px;">
|
||||||
|
<legend>Shared Max Upload Size (bytes)</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="number" id="sharedMaxUploadSize" class="form-control"
|
||||||
|
placeholder="e.g. 52428800" />
|
||||||
|
<small>Enter maximum bytes allowed for shared-folder uploads</small>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<!-- End Shared Max Upload Size setting -->
|
||||||
|
|
||||||
<fieldset style="margin-bottom: 15px;">
|
<fieldset style="margin-bottom: 15px;">
|
||||||
<legend>${t("oidc_configuration")}</legend>
|
<legend>${t("oidc_configuration")}</legend>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -698,33 +748,34 @@ export function openAdminPanel() {
|
|||||||
`;
|
`;
|
||||||
document.body.appendChild(adminModal);
|
document.body.appendChild(adminModal);
|
||||||
|
|
||||||
// Bind closing events that will use our enhanced close function.
|
// Bind closing
|
||||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||||
adminModal.addEventListener("click", (e) => {
|
adminModal.addEventListener("click", e => { if (e.target === adminModal) closeAdminPanel(); });
|
||||||
if (e.target === adminModal) closeAdminPanel();
|
|
||||||
});
|
|
||||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||||
|
|
||||||
// Bind other buttons.
|
// Bind other buttons
|
||||||
document.getElementById("adminOpenAddUser").addEventListener("click", () => {
|
document.getElementById("adminOpenAddUser").addEventListener("click", () => {
|
||||||
toggleVisibility("addUserModal", true);
|
toggleVisibility("addUserModal", true);
|
||||||
document.getElementById("newUsername").focus();
|
document.getElementById("newUsername").focus();
|
||||||
});
|
});
|
||||||
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
|
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
|
||||||
if (typeof window.loadUserList === "function") {
|
if (typeof window.loadUserList === "function") window.loadUserList();
|
||||||
window.loadUserList();
|
|
||||||
}
|
|
||||||
toggleVisibility("removeUserModal", true);
|
toggleVisibility("removeUserModal", true);
|
||||||
});
|
});
|
||||||
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
|
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
|
||||||
openUserPermissionsModal();
|
openUserPermissionsModal();
|
||||||
});
|
});
|
||||||
document.getElementById("saveAdminSettings").addEventListener("click", () => {
|
|
||||||
|
|
||||||
|
// Save handler
|
||||||
|
document.getElementById("saveAdminSettings").addEventListener("click", () => {
|
||||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
const enableWebDAVCheckbox = document.getElementById("enableWebDAV");
|
||||||
|
const sharedMaxUploadSizeInput = document.getElementById("sharedMaxUploadSize");
|
||||||
|
|
||||||
|
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox]
|
||||||
|
.filter(cb => cb.checked).length;
|
||||||
if (totalDisabled === 3) {
|
if (totalDisabled === 3) {
|
||||||
showToast(t("at_least_one_login_method"));
|
showToast(t("at_least_one_login_method"));
|
||||||
disableOIDCLoginCheckbox.checked = false;
|
disableOIDCLoginCheckbox.checked = false;
|
||||||
@@ -738,8 +789,8 @@ export function openAdminPanel() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
|
|
||||||
|
|
||||||
|
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
|
||||||
const newOIDCConfig = {
|
const newOIDCConfig = {
|
||||||
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
||||||
clientId: document.getElementById("oidcClientId").value.trim(),
|
clientId: document.getElementById("oidcClientId").value.trim(),
|
||||||
@@ -749,13 +800,18 @@ export function openAdminPanel() {
|
|||||||
const disableFormLogin = disableFormLoginCheckbox.checked;
|
const disableFormLogin = disableFormLoginCheckbox.checked;
|
||||||
const disableBasicAuth = disableBasicAuthCheckbox.checked;
|
const disableBasicAuth = disableBasicAuthCheckbox.checked;
|
||||||
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
|
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
|
||||||
|
const enableWebDAV = enableWebDAVCheckbox.checked;
|
||||||
|
const sharedMaxUploadSize = parseInt(sharedMaxUploadSizeInput.value, 10) || 0;
|
||||||
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
|
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
|
||||||
|
|
||||||
sendRequest("/api/admin/updateConfig.php", "POST", {
|
sendRequest("/api/admin/updateConfig.php", "POST", {
|
||||||
header_title: newHeaderTitle,
|
header_title: newHeaderTitle,
|
||||||
oidc: newOIDCConfig,
|
oidc: newOIDCConfig,
|
||||||
disableFormLogin,
|
disableFormLogin,
|
||||||
disableBasicAuth,
|
disableBasicAuth,
|
||||||
disableOIDCLogin,
|
disableOIDCLogin,
|
||||||
|
enableWebDAV,
|
||||||
|
sharedMaxUploadSize,
|
||||||
globalOtpauthUrl
|
globalOtpauthUrl
|
||||||
}, { "X-CSRF-Token": window.csrfToken })
|
}, { "X-CSRF-Token": window.csrfToken })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -764,26 +820,32 @@ export function openAdminPanel() {
|
|||||||
localStorage.setItem("disableFormLogin", disableFormLogin);
|
localStorage.setItem("disableFormLogin", disableFormLogin);
|
||||||
localStorage.setItem("disableBasicAuth", disableBasicAuth);
|
localStorage.setItem("disableBasicAuth", disableBasicAuth);
|
||||||
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
|
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
|
||||||
|
localStorage.setItem("enableWebDAV", enableWebDAV);
|
||||||
|
localStorage.setItem("sharedMaxUploadSize", sharedMaxUploadSize);
|
||||||
if (typeof window.updateLoginOptionsUI === "function") {
|
if (typeof window.updateLoginOptionsUI === "function") {
|
||||||
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
|
window.updateLoginOptionsUI({
|
||||||
|
disableFormLogin,
|
||||||
|
disableBasicAuth,
|
||||||
|
disableOIDCLogin
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Update the captured initial state since the changes have now been saved.
|
|
||||||
captureInitialAdminConfig();
|
captureInitialAdminConfig();
|
||||||
closeAdminPanel();
|
closeAdminPanel();
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error")));
|
showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error")));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enforce login option constraints.
|
// Enforce login option constraints.
|
||||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||||
function enforceLoginOptionConstraint(changedCheckbox) {
|
function enforceLoginOptionConstraint(changedCheckbox) {
|
||||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox]
|
||||||
|
.filter(cb => cb.checked).length;
|
||||||
if (changedCheckbox.checked && totalDisabled === 3) {
|
if (changedCheckbox.checked && totalDisabled === 3) {
|
||||||
showToast(t("at_least_one_login_method"));
|
showToast(t("at_least_one_login_method"));
|
||||||
changedCheckbox.checked = false;
|
changedCheckbox.checked = false;
|
||||||
@@ -793,13 +855,17 @@ export function openAdminPanel() {
|
|||||||
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||||
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||||
|
|
||||||
|
// Initial checkbox and input states
|
||||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||||
|
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
|
||||||
|
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
||||||
|
|
||||||
// Capture initial state after the modal loads.
|
|
||||||
captureInitialAdminConfig();
|
captureInitialAdminConfig();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
// Update existing modal and show
|
||||||
adminModal.style.backgroundColor = overlayBackground;
|
adminModal.style.backgroundColor = overlayBackground;
|
||||||
const modalContent = adminModal.querySelector(".modal-content");
|
const modalContent = adminModal.querySelector(".modal-content");
|
||||||
if (modalContent) {
|
if (modalContent) {
|
||||||
@@ -815,6 +881,8 @@ export function openAdminPanel() {
|
|||||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||||
|
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
|
||||||
|
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
||||||
adminModal.style.display = "flex";
|
adminModal.style.display = "flex";
|
||||||
captureInitialAdminConfig();
|
captureInitialAdminConfig();
|
||||||
}
|
}
|
||||||
@@ -837,6 +905,8 @@ export function openAdminPanel() {
|
|||||||
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
|
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
|
||||||
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
|
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
|
||||||
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
|
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
|
||||||
|
document.getElementById("enableWebDAV").checked = localStorage.getItem("enableWebDAV") === "true";
|
||||||
|
document.getElementById("sharedMaxUploadSize").value = localStorage.getItem("sharedMaxUploadSize") || "";
|
||||||
adminModal.style.display = "flex";
|
adminModal.style.display = "flex";
|
||||||
captureInitialAdminConfig();
|
captureInitialAdminConfig();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ export function toggleAllCheckboxes(masterCheckbox) {
|
|||||||
const checkboxes = document.querySelectorAll(".file-checkbox");
|
const checkboxes = document.querySelectorAll(".file-checkbox");
|
||||||
checkboxes.forEach(chk => {
|
checkboxes.forEach(chk => {
|
||||||
chk.checked = masterCheckbox.checked;
|
chk.checked = masterCheckbox.checked;
|
||||||
|
updateRowHighlight(chk);
|
||||||
});
|
});
|
||||||
updateFileActionButtons(); // update buttons based on current selection
|
updateFileActionButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateFileActionButtons() {
|
export function updateFileActionButtons() {
|
||||||
@@ -38,6 +39,21 @@ export function updateFileActionButtons() {
|
|||||||
const zipBtn = document.getElementById("downloadZipBtn");
|
const zipBtn = document.getElementById("downloadZipBtn");
|
||||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||||
|
|
||||||
|
// keep the “select all” in sync ——
|
||||||
|
const master = document.getElementById("selectAll");
|
||||||
|
if (master) {
|
||||||
|
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
||||||
|
master.checked = true;
|
||||||
|
master.indeterminate = false;
|
||||||
|
} else if (selectedCheckboxes.length === 0) {
|
||||||
|
master.checked = false;
|
||||||
|
master.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
master.checked = false;
|
||||||
|
master.indeterminate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fileCheckboxes.length === 0) {
|
if (fileCheckboxes.length === 0) {
|
||||||
if (copyBtn) copyBtn.style.display = "none";
|
if (copyBtn) copyBtn.style.display = "none";
|
||||||
if (moveBtn) moveBtn.style.display = "none";
|
if (moveBtn) moveBtn.style.display = "none";
|
||||||
@@ -91,7 +107,7 @@ export function showToast(message, duration = 3000) {
|
|||||||
export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) {
|
export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) {
|
||||||
const safeSearchTerm = escapeHTML(searchTerm);
|
const safeSearchTerm = escapeHTML(searchTerm);
|
||||||
// Choose the placeholder text based on advanced search mode
|
// Choose the placeholder text based on advanced search mode
|
||||||
const placeholderText = window.advancedSearchEnabled
|
const placeholderText = window.advancedSearchEnabled
|
||||||
? t("search_placeholder_advanced")
|
? t("search_placeholder_advanced")
|
||||||
: t("search_placeholder");
|
: t("search_placeholder");
|
||||||
|
|
||||||
@@ -101,7 +117,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
|||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<!-- Advanced Search Toggle Button -->
|
<!-- Advanced Search Toggle Button -->
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" onclick="toggleAdvancedSearch()" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
|
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
|
||||||
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
|
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,9 +133,9 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4 text-left">
|
<div class="col-12 col-md-4 text-left">
|
||||||
<div class="d-flex justify-content-center justify-content-md-start align-items-center">
|
<div class="d-flex justify-content-center justify-content-md-start align-items-center">
|
||||||
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${t("prev")}</button>
|
<button id="prevPageBtn" class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""}>${t("prev")}</button>
|
||||||
<span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span>
|
<span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span>
|
||||||
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">${t("next")}</button>
|
<button id="nextPageBtn" class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""}>${t("next")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +147,7 @@ export function buildFileTableHeader(sortOrder) {
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th>
|
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
||||||
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
@@ -162,15 +178,15 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" onclick="event.stopPropagation(); previewFile('${folderPath + encodeURIComponent(file.name)}', '${safeFileName}')">
|
previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
|
||||||
${previewIcon}
|
${previewIcon}
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr onclick="toggleRowSelection(event, '${safeFileName}')" class="clickable-row">
|
<tr class="clickable-row">
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);">
|
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||||
</td>
|
</td>
|
||||||
<td class="file-name-cell">${safeFileName}</td>
|
<td class="file-name-cell">${safeFileName}</td>
|
||||||
<td class="hide-small nowrap">${safeModified}</td>
|
<td class="hide-small nowrap">${safeModified}</td>
|
||||||
@@ -179,22 +195,16 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn"
|
<button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
|
||||||
onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')"
|
|
||||||
title="${t('download')}">
|
|
||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</button>
|
</button>
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn"
|
<button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
|
||||||
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
|
||||||
title="${t('edit')}">
|
|
||||||
<i class="material-icons">edit</i>
|
<i class="material-icons">edit</i>
|
||||||
</button>
|
</button>
|
||||||
` : ""}
|
` : ""}
|
||||||
${previewButton}
|
${previewButton}
|
||||||
<button class="btn btn-sm btn-warning rename-btn"
|
<button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
|
||||||
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
|
||||||
title="${t('rename')}">
|
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,10 +217,10 @@ export function buildBottomControls(itemsPerPageSetting) {
|
|||||||
return `
|
return `
|
||||||
<div class="d-flex align-items-center mt-3 bottom-controls">
|
<div class="d-flex align-items-center mt-3 bottom-controls">
|
||||||
<label class="label-inline mr-2 mb-0">${t("show")}</label>
|
<label class="label-inline mr-2 mb-0">${t("show")}</label>
|
||||||
<select class="form-control bottom-select" onchange="changeItemsPerPage(this.value)">
|
<select class="form-control bottom-select" id="itemsPerPageSelect">
|
||||||
${[10, 20, 50, 100]
|
${[10, 20, 50, 100]
|
||||||
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
|
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
|
||||||
.join("")}
|
.join("")}
|
||||||
</select>
|
</select>
|
||||||
<span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span>
|
<span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,8 +287,6 @@ export function toggleRowSelection(event, fileName) {
|
|||||||
const start = Math.min(currentIndex, lastIndex);
|
const start = Math.min(currentIndex, lastIndex);
|
||||||
const end = Math.max(currentIndex, lastIndex);
|
const end = Math.max(currentIndex, lastIndex);
|
||||||
|
|
||||||
// If neither CTRL nor Meta is pressed, you might choose
|
|
||||||
// to clear existing selections. For this example we leave existing selections intact.
|
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
const cb = allRows[i].querySelector(".file-checkbox");
|
const cb = allRows[i].querySelector(".file-checkbox");
|
||||||
if (cb) {
|
if (cb) {
|
||||||
@@ -345,4 +353,7 @@ export function showCustomConfirmModal(message) {
|
|||||||
yesBtn.addEventListener("click", onYes);
|
yesBtn.addEventListener("click", onYes);
|
||||||
noBtn.addEventListener("click", onNo);
|
noBtn.addEventListener("click", onNo);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.toggleRowSelection = toggleRowSelection;
|
||||||
|
window.updateRowHighlight = updateRowHighlight;
|
||||||
@@ -80,16 +80,16 @@ export function openDownloadModal(fileName, folder) {
|
|||||||
// Store file details globally for the download confirmation function.
|
// Store file details globally for the download confirmation function.
|
||||||
window.singleFileToDownload = fileName;
|
window.singleFileToDownload = fileName;
|
||||||
window.currentFolder = folder || "root";
|
window.currentFolder = folder || "root";
|
||||||
|
|
||||||
// Optionally pre-fill the file name input in the modal.
|
// Optionally pre-fill the file name input in the modal.
|
||||||
const input = document.getElementById("downloadFileNameInput");
|
const input = document.getElementById("downloadFileNameInput");
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = fileName; // Use file name as-is (or modify if desired)
|
input.value = fileName; // Use file name as-is (or modify if desired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the single file download modal (a new modal element).
|
// Show the single file download modal (a new modal element).
|
||||||
document.getElementById("downloadFileModal").style.display = "block";
|
document.getElementById("downloadFileModal").style.display = "block";
|
||||||
|
|
||||||
// Optionally focus the input after a short delay.
|
// Optionally focus the input after a short delay.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
@@ -97,58 +97,34 @@ export function openDownloadModal(fileName, folder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function confirmSingleDownload() {
|
export function confirmSingleDownload() {
|
||||||
// Get the file name from the modal. Users can change it if desired.
|
// 1) Get and validate the filename
|
||||||
let fileName = document.getElementById("downloadFileNameInput").value.trim();
|
const input = document.getElementById("downloadFileNameInput");
|
||||||
|
const fileName = input.value.trim();
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
showToast("Please enter a name for the file.");
|
showToast("Please enter a name for the file.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the download modal.
|
// 2) Hide the download-name modal
|
||||||
document.getElementById("downloadFileModal").style.display = "none";
|
document.getElementById("downloadFileModal").style.display = "none";
|
||||||
// Show the progress modal (same as in your ZIP download flow).
|
|
||||||
document.getElementById("downloadProgressModal").style.display = "block";
|
// 3) Build the direct download URL
|
||||||
|
|
||||||
// Build the URL for download.php using GET parameters.
|
|
||||||
const folder = window.currentFolder || "root";
|
const folder = window.currentFolder || "root";
|
||||||
const downloadURL = "/api/file/download.php?folder=" + encodeURIComponent(folder) +
|
const downloadURL = "/api/file/download.php"
|
||||||
"&file=" + encodeURIComponent(window.singleFileToDownload);
|
+ "?folder=" + encodeURIComponent(folder)
|
||||||
|
+ "&file=" + encodeURIComponent(window.singleFileToDownload);
|
||||||
fetch(downloadURL, {
|
|
||||||
method: "GET",
|
// 4) Trigger native browser download
|
||||||
credentials: "include"
|
const a = document.createElement("a");
|
||||||
})
|
a.href = downloadURL;
|
||||||
.then(response => {
|
a.download = fileName;
|
||||||
if (!response.ok) {
|
a.style.display = "none";
|
||||||
return response.text().then(text => {
|
document.body.appendChild(a);
|
||||||
throw new Error("Failed to download file: " + text);
|
a.click();
|
||||||
});
|
document.body.removeChild(a);
|
||||||
}
|
|
||||||
return response.blob();
|
// 5) Notify the user
|
||||||
})
|
showToast("Download started. Check your browser’s download manager.");
|
||||||
.then(blob => {
|
|
||||||
if (!blob || blob.size === 0) {
|
|
||||||
throw new Error("Received empty file.");
|
|
||||||
}
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.style.display = "none";
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
a.remove();
|
|
||||||
// Hide the progress modal.
|
|
||||||
document.getElementById("downloadProgressModal").style.display = "none";
|
|
||||||
showToast("Download started.");
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
// Hide progress modal and show error.
|
|
||||||
document.getElementById("downloadProgressModal").style.display = "none";
|
|
||||||
console.error("Error downloading file:", error);
|
|
||||||
showToast("Error downloading file: " + error.message);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleExtractZipSelected(e) {
|
export function handleExtractZipSelected(e) {
|
||||||
@@ -168,16 +144,21 @@ export function handleExtractZipSelected(e) {
|
|||||||
showToast("No zip files selected.");
|
showToast("No zip files selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change progress modal text to "Extracting files..."
|
// Prepare and show the spinner-only modal
|
||||||
const progressText = document.querySelector("#downloadProgressModal p");
|
const modal = document.getElementById("downloadProgressModal");
|
||||||
if (progressText) {
|
const titleEl = document.getElementById("downloadProgressTitle");
|
||||||
progressText.textContent = "Extracting files...";
|
const spinner = modal.querySelector(".download-spinner");
|
||||||
}
|
const progressBar = document.getElementById("downloadProgressBar");
|
||||||
|
const progressPct = document.getElementById("downloadProgressPercent");
|
||||||
// Show the progress modal.
|
|
||||||
document.getElementById("downloadProgressModal").style.display = "block";
|
if (titleEl) titleEl.textContent = "Extracting files…";
|
||||||
|
if (spinner) spinner.style.display = "inline-block";
|
||||||
|
if (progressBar) progressBar.style.display = "none";
|
||||||
|
if (progressPct) progressPct.style.display = "none";
|
||||||
|
|
||||||
|
modal.style.display = "block";
|
||||||
|
|
||||||
fetch("/api/file/extractZip.php", {
|
fetch("/api/file/extractZip.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -192,45 +173,42 @@ export function handleExtractZipSelected(e) {
|
|||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Hide the progress modal once the request has completed.
|
modal.style.display = "none";
|
||||||
document.getElementById("downloadProgressModal").style.display = "none";
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
let toastMessage = "Zip file(s) extracted successfully!";
|
let msg = "Zip file(s) extracted successfully!";
|
||||||
if (data.extractedFiles && Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
|
if (Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
|
||||||
toastMessage = "Extracted: " + data.extractedFiles.join(", ");
|
msg = "Extracted: " + data.extractedFiles.join(", ");
|
||||||
}
|
}
|
||||||
showToast(toastMessage);
|
showToast(msg);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error extracting zip: " + (data.error || "Unknown error"));
|
showToast("Error extracting zip: " + (data.error || "Unknown error"));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Hide the progress modal on error.
|
modal.style.display = "none";
|
||||||
document.getElementById("downloadProgressModal").style.display = "none";
|
|
||||||
console.error("Error extracting zip files:", error);
|
console.error("Error extracting zip files:", error);
|
||||||
showToast("Error extracting zip files.");
|
showToast("Error extracting zip files.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
if (extractZipBtn) {
|
const zipNameModal = document.getElementById("downloadZipModal");
|
||||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
const progressModal = document.getElementById("downloadProgressModal");
|
||||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
}
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
// 1) Cancel button hides the name modal
|
||||||
const cancelDownloadZip = document.getElementById("cancelDownloadZip");
|
if (cancelZipBtn) {
|
||||||
if (cancelDownloadZip) {
|
cancelZipBtn.addEventListener("click", () => {
|
||||||
cancelDownloadZip.addEventListener("click", function () {
|
zipNameModal.style.display = "none";
|
||||||
document.getElementById("downloadZipModal").style.display = "none";
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// This part remains in your confirmDownloadZip event handler:
|
// 2) Confirm button kicks off the zip+download
|
||||||
const confirmDownloadZip = document.getElementById("confirmDownloadZip");
|
if (confirmZipBtn) {
|
||||||
if (confirmDownloadZip) {
|
confirmZipBtn.addEventListener("click", async () => {
|
||||||
confirmDownloadZip.addEventListener("click", function () {
|
// a) Validate ZIP filename
|
||||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||||
if (!zipName) {
|
if (!zipName) {
|
||||||
showToast("Please enter a name for the zip file.");
|
showToast("Please enter a name for the zip file.");
|
||||||
@@ -239,52 +217,56 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
if (!zipName.toLowerCase().endsWith(".zip")) {
|
||||||
zipName += ".zip";
|
zipName += ".zip";
|
||||||
}
|
}
|
||||||
// Hide the ZIP name input modal
|
|
||||||
document.getElementById("downloadZipModal").style.display = "none";
|
// b) Hide the name‐input modal, show the spinner modal
|
||||||
// Show the progress modal here only on confirm
|
zipNameModal.style.display = "none";
|
||||||
console.log("Download confirmed. Showing progress modal.");
|
progressModal.style.display = "block";
|
||||||
document.getElementById("downloadProgressModal").style.display = "block";
|
|
||||||
const folder = window.currentFolder || "root";
|
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
||||||
fetch("/api/file/downloadZip.php", {
|
const titleEl = document.getElementById("downloadProgressTitle");
|
||||||
method: "POST",
|
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
try {
|
||||||
"Content-Type": "application/json",
|
// d) POST and await the ZIP blob
|
||||||
"X-CSRF-Token": window.csrfToken
|
const res = await fetch("/api/file/downloadZip.php", {
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({ folder: folder, files: window.filesToDownload })
|
credentials: "include",
|
||||||
})
|
headers: {
|
||||||
.then(response => {
|
"Content-Type": "application/json",
|
||||||
if (!response.ok) {
|
"X-CSRF-Token": window.csrfToken
|
||||||
return response.text().then(text => {
|
},
|
||||||
throw new Error("Failed to create zip file: " + text);
|
body: JSON.stringify({
|
||||||
});
|
folder: window.currentFolder || "root",
|
||||||
}
|
files: window.filesToDownload
|
||||||
return response.blob();
|
})
|
||||||
})
|
|
||||||
.then(blob => {
|
|
||||||
if (!blob || blob.size === 0) {
|
|
||||||
throw new Error("Received empty zip file.");
|
|
||||||
}
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.style.display = "none";
|
|
||||||
a.href = url;
|
|
||||||
a.download = zipName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
a.remove();
|
|
||||||
// Hide the progress modal after download starts
|
|
||||||
document.getElementById("downloadProgressModal").style.display = "none";
|
|
||||||
showToast("Download started.");
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
// Hide the progress modal on error
|
|
||||||
document.getElementById("downloadProgressModal").style.display = "none";
|
|
||||||
console.error("Error downloading zip:", error);
|
|
||||||
showToast("Error downloading selected files as zip: " + error.message);
|
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(txt || `Status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (!blob || blob.size === 0) {
|
||||||
|
throw new Error("Received empty ZIP file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// e) Hand off to the browser’s download manager
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = zipName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error downloading ZIP:", err);
|
||||||
|
showToast("Error: " + err.message);
|
||||||
|
} finally {
|
||||||
|
// f) Always hide spinner modal
|
||||||
|
progressModal.style.display = "none";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -573,4 +555,22 @@ export function initFileActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook up the single‐file download modal buttons
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
||||||
|
if (cancelDownloadFileBtn) {
|
||||||
|
cancelDownloadFileBtn.addEventListener("click", () => {
|
||||||
|
document.getElementById("downloadFileModal").style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSingleDownloadBtn = document.getElementById("confirmSingleDownloadButton");
|
||||||
|
if (confirmSingleDownloadBtn) {
|
||||||
|
confirmSingleDownloadBtn.addEventListener("click", confirmSingleDownload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make Enter also confirm the download
|
||||||
|
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
||||||
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -340,6 +340,88 @@ export function renderFileTable(folder, container) {
|
|||||||
|
|
||||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||||
|
|
||||||
|
// pagination clicks
|
||||||
|
const prevBtn = document.getElementById("prevPageBtn");
|
||||||
|
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||||||
|
if (window.currentPage > 1) {
|
||||||
|
window.currentPage--;
|
||||||
|
renderFileTable(folder, container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const nextBtn = document.getElementById("nextPageBtn");
|
||||||
|
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||||||
|
// totalPages is computed above in this scope
|
||||||
|
if (window.currentPage < totalPages) {
|
||||||
|
window.currentPage++;
|
||||||
|
renderFileTable(folder, container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ADD: advanced search toggle
|
||||||
|
const advToggle = document.getElementById("advancedSearchToggle");
|
||||||
|
if (advToggle) advToggle.addEventListener("click", () => {
|
||||||
|
toggleAdvancedSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// items-per-page selector
|
||||||
|
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||||||
|
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||||||
|
window.itemsPerPage = parseInt(e.target.value, 10);
|
||||||
|
localStorage.setItem("itemsPerPage", window.itemsPerPage);
|
||||||
|
window.currentPage = 1;
|
||||||
|
renderFileTable(folder, container);
|
||||||
|
});
|
||||||
|
|
||||||
|
// hook up the master checkbox
|
||||||
|
const selectAll = document.getElementById("selectAll");
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener("change", () => {
|
||||||
|
toggleAllCheckboxes(selectAll);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Row-click selects the row
|
||||||
|
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
||||||
|
row.addEventListener("click", e => {
|
||||||
|
// grab the underlying checkbox value
|
||||||
|
const cb = row.querySelector(".file-checkbox");
|
||||||
|
if (!cb) return;
|
||||||
|
toggleRowSelection(e, cb.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Download buttons
|
||||||
|
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Edit buttons
|
||||||
|
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) Rename buttons
|
||||||
|
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Preview buttons (if you still have a .preview-btn)
|
||||||
|
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
createViewToggleButton();
|
createViewToggleButton();
|
||||||
|
|
||||||
// Setup event listeners.
|
// Setup event listeners.
|
||||||
@@ -476,23 +558,26 @@ export function renderGalleryView(folder, container) {
|
|||||||
|
|
||||||
pageFiles.forEach((file, idx) => {
|
pageFiles.forEach((file, idx) => {
|
||||||
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
|
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
|
||||||
|
const cacheKey = folderPath + encodeURIComponent(file.name);
|
||||||
|
|
||||||
// thumbnail
|
// thumbnail
|
||||||
let thumbnail;
|
let thumbnail;
|
||||||
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
||||||
const cacheKey = folderPath + encodeURIComponent(file.name);
|
|
||||||
if (window.imageCache && window.imageCache[cacheKey]) {
|
if (window.imageCache && window.imageCache[cacheKey]) {
|
||||||
thumbnail = `<img src="${window.imageCache[cacheKey]}"
|
thumbnail = `<img
|
||||||
class="gallery-thumbnail"
|
src="${window.imageCache[cacheKey]}"
|
||||||
alt="${escapeHTML(file.name)}"
|
class="gallery-thumbnail"
|
||||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
data-cache-key="${cacheKey}"
|
||||||
|
alt="${escapeHTML(file.name)}"
|
||||||
|
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||||
} else {
|
} else {
|
||||||
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
|
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
|
||||||
thumbnail = `<img src="${imageUrl}"
|
thumbnail = `<img
|
||||||
onload="cacheImage(this,'${cacheKey}')"
|
src="${imageUrl}"
|
||||||
class="gallery-thumbnail"
|
class="gallery-thumbnail"
|
||||||
alt="${escapeHTML(file.name)}"
|
data-cache-key="${cacheKey}"
|
||||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
alt="${escapeHTML(file.name)}"
|
||||||
|
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||||
}
|
}
|
||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
|
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
|
||||||
@@ -529,9 +614,9 @@ export function renderGalleryView(folder, container) {
|
|||||||
<label for="cb-${idSafe}"
|
<label for="cb-${idSafe}"
|
||||||
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
|
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
|
||||||
|
|
||||||
<div class="gallery-preview"
|
<div class="gallery-preview" style="cursor:pointer;"
|
||||||
style="cursor:pointer;"
|
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||||
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')">
|
data-preview-name="${file.name}">
|
||||||
${thumbnail}
|
${thumbnail}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -544,22 +629,25 @@ export function renderGalleryView(folder, container) {
|
|||||||
|
|
||||||
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
|
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn"
|
<button type="button" class="btn btn-sm btn-success download-btn"
|
||||||
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')"
|
data-download-name="${escapeHTML(file.name)}"
|
||||||
|
data-download-folder="${file.folder || "root"}"
|
||||||
title="${t('download')}">
|
title="${t('download')}">
|
||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</button>
|
</button>
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn"
|
<button type="button" class="btn btn-sm edit-btn"
|
||||||
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
data-edit-name="${escapeHTML(file.name)}"
|
||||||
title="${t('Edit')}">
|
data-edit-folder="${file.folder || "root"}"
|
||||||
|
title="${t('edit')}">
|
||||||
<i class="material-icons">edit</i>
|
<i class="material-icons">edit</i>
|
||||||
</button>` : ""}
|
</button>` : ""}
|
||||||
<button class="btn btn-sm btn-warning rename-btn"
|
<button type="button" class="btn btn-sm btn-warning rename-btn"
|
||||||
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
data-rename-name="${escapeHTML(file.name)}"
|
||||||
|
data-rename-folder="${file.folder || "root"}"
|
||||||
title="${t('rename')}">
|
title="${t('rename')}">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-secondary share-btn"
|
<button type="button" class="btn btn-sm btn-secondary share-btn"
|
||||||
data-file="${escapeHTML(file.name)}"
|
data-file="${escapeHTML(file.name)}"
|
||||||
title="${t('share')}">
|
title="${t('share')}">
|
||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
@@ -579,13 +667,93 @@ export function renderGalleryView(folder, container) {
|
|||||||
// render
|
// render
|
||||||
fileListContent.innerHTML = galleryHTML;
|
fileListContent.innerHTML = galleryHTML;
|
||||||
|
|
||||||
// ensure toggle button
|
// --- Now wire up all behaviors without inline handlers ---
|
||||||
createViewToggleButton();
|
|
||||||
|
|
||||||
// attach listeners
|
// ADD: pagination buttons for gallery
|
||||||
|
const prevBtn = document.getElementById("prevPageBtn");
|
||||||
|
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||||||
|
if (window.currentPage > 1) {
|
||||||
|
window.currentPage--;
|
||||||
|
renderGalleryView(folder, container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const nextBtn = document.getElementById("nextPageBtn");
|
||||||
|
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||||||
|
if (window.currentPage < totalPages) {
|
||||||
|
window.currentPage++;
|
||||||
|
renderGalleryView(folder, container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ←— ADD: advanced search toggle
|
||||||
|
const advToggle = document.getElementById("advancedSearchToggle");
|
||||||
|
if (advToggle) advToggle.addEventListener("click", () => {
|
||||||
|
toggleAdvancedSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ←— ADD: wire up context-menu in gallery
|
||||||
|
bindFileListContextMenu();
|
||||||
|
|
||||||
|
// ADD: items-per-page selector for gallery
|
||||||
|
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||||||
|
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||||||
|
window.itemsPerPage = parseInt(e.target.value, 10);
|
||||||
|
localStorage.setItem("itemsPerPage", window.itemsPerPage);
|
||||||
|
window.currentPage = 1;
|
||||||
|
renderGalleryView(folder, container);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cache images on load
|
||||||
|
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
|
||||||
|
const key = img.dataset.cacheKey;
|
||||||
|
img.addEventListener('load', () => cacheImage(img, key));
|
||||||
|
});
|
||||||
|
|
||||||
|
// preview clicks
|
||||||
|
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// download clicks
|
||||||
|
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// edit clicks
|
||||||
|
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// rename clicks
|
||||||
|
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// share clicks
|
||||||
|
fileListContent.querySelectorAll(".share-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const fileName = btn.dataset.file;
|
||||||
|
const fileObj = fileData.find(f => f.name === fileName);
|
||||||
|
if (fileObj) {
|
||||||
|
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// checkboxes
|
// checkboxes
|
||||||
document.querySelectorAll(".file-checkbox").forEach(cb => {
|
fileListContent.querySelectorAll(".file-checkbox").forEach(cb => {
|
||||||
cb.addEventListener("change", () => updateFileActionButtons());
|
cb.addEventListener("change", () => updateFileActionButtons());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,14 +771,13 @@ export function renderGalleryView(folder, container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagination
|
// pagination functions
|
||||||
window.changePage = newPage => {
|
window.changePage = newPage => {
|
||||||
window.currentPage = newPage;
|
window.currentPage = newPage;
|
||||||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||||||
else renderFileTable(folder);
|
else renderFileTable(folder);
|
||||||
};
|
};
|
||||||
|
|
||||||
// items per page
|
|
||||||
window.changeItemsPerPage = cnt => {
|
window.changeItemsPerPage = cnt => {
|
||||||
window.itemsPerPage = +cnt;
|
window.itemsPerPage = +cnt;
|
||||||
localStorage.setItem("itemsPerPage", cnt);
|
localStorage.setItem("itemsPerPage", cnt);
|
||||||
@@ -619,8 +786,9 @@ export function renderGalleryView(folder, container) {
|
|||||||
else renderFileTable(folder);
|
else renderFileTable(folder);
|
||||||
};
|
};
|
||||||
|
|
||||||
// update toolbar buttons
|
// update toolbar and toggle button
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
|
createViewToggleButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive slider constraints based on screen size.
|
// Responsive slider constraints based on screen size.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { loadFileList } from './fileListView.js';
|
|||||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { openFolderShareModal } from './folderShareModal.js';
|
import { openFolderShareModal } from './folderShareModal.js';
|
||||||
|
import { fetchWithCsrf } from './auth.js';
|
||||||
|
import { loadCsrfToken } from './main.js';
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
Helper Functions (Data/State)
|
Helper Functions (Data/State)
|
||||||
@@ -102,24 +104,26 @@ export function setupBreadcrumbDelegation() {
|
|||||||
|
|
||||||
// Click handler via delegation
|
// Click handler via delegation
|
||||||
function breadcrumbClickHandler(e) {
|
function breadcrumbClickHandler(e) {
|
||||||
|
// find the nearest .breadcrumb-link
|
||||||
const link = e.target.closest(".breadcrumb-link");
|
const link = e.target.closest(".breadcrumb-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const folder = link.getAttribute("data-folder");
|
const folder = link.dataset.folder;
|
||||||
window.currentFolder = folder;
|
window.currentFolder = folder;
|
||||||
localStorage.setItem("lastOpenedFolder", folder);
|
localStorage.setItem("lastOpenedFolder", folder);
|
||||||
|
|
||||||
// Update the container with sanitized breadcrumbs.
|
// rebuild the title safely
|
||||||
const container = document.getElementById("fileListTitle");
|
updateBreadcrumbTitle(folder);
|
||||||
const sanitizedBreadcrumb = DOMPurify.sanitize(renderBreadcrumb(folder));
|
|
||||||
container.innerHTML = t("files_in") + " (" + sanitizedBreadcrumb + ")";
|
|
||||||
|
|
||||||
expandTreePath(folder);
|
expandTreePath(folder);
|
||||||
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
document.querySelectorAll(".folder-option").forEach(el =>
|
||||||
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
el.classList.remove("selected")
|
||||||
if (targetOption) targetOption.classList.add("selected");
|
);
|
||||||
|
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||||
|
if (target) target.classList.add("selected");
|
||||||
|
|
||||||
loadFileList(folder);
|
loadFileList(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,11 +337,43 @@ function folderDropHandler(event) {
|
|||||||
/* ----------------------
|
/* ----------------------
|
||||||
Main Folder Tree Rendering and Event Binding
|
Main Folder Tree Rendering and Event Binding
|
||||||
----------------------*/
|
----------------------*/
|
||||||
|
// --- Helpers for safe breadcrumb rendering ---
|
||||||
|
function renderBreadcrumbFragment(folderPath) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const parts = folderPath.split("/");
|
||||||
|
let acc = "";
|
||||||
|
|
||||||
|
parts.forEach((part, idx) => {
|
||||||
|
acc = idx === 0 ? part : acc + "/" + part;
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.classList.add("breadcrumb-link");
|
||||||
|
span.dataset.folder = acc;
|
||||||
|
span.textContent = part;
|
||||||
|
frag.appendChild(span);
|
||||||
|
|
||||||
|
if (idx < parts.length - 1) {
|
||||||
|
frag.appendChild(document.createTextNode(" / "));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBreadcrumbTitle(folder) {
|
||||||
|
const titleEl = document.getElementById("fileListTitle");
|
||||||
|
titleEl.textContent = "";
|
||||||
|
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
||||||
|
titleEl.appendChild(renderBreadcrumbFragment(folder));
|
||||||
|
titleEl.appendChild(document.createTextNode(")"));
|
||||||
|
setupBreadcrumbDelegation();
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadFolderTree(selectedFolder) {
|
export async function loadFolderTree(selectedFolder) {
|
||||||
try {
|
try {
|
||||||
// Check if the user has folder-only permission.
|
// Check if the user has folder-only permission.
|
||||||
await checkUserFolderPermission();
|
await checkUserFolderPermission();
|
||||||
|
|
||||||
// Determine effective root folder.
|
// Determine effective root folder.
|
||||||
const username = localStorage.getItem("username") || "root";
|
const username = localStorage.getItem("username") || "root";
|
||||||
let effectiveRoot = "root";
|
let effectiveRoot = "root";
|
||||||
@@ -351,14 +387,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
} else {
|
} else {
|
||||||
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build fetch URL.
|
// Build fetch URL.
|
||||||
let fetchUrl = '/api/folder/getFolderList.php';
|
let fetchUrl = '/api/folder/getFolderList.php';
|
||||||
if (window.userFolderOnly) {
|
if (window.userFolderOnly) {
|
||||||
fetchUrl += '?restricted=1';
|
fetchUrl += '?restricted=1';
|
||||||
}
|
}
|
||||||
console.log("Fetching folder list from:", fetchUrl);
|
console.log("Fetching folder list from:", fetchUrl);
|
||||||
|
|
||||||
// Fetch folder list from the server.
|
// Fetch folder list from the server.
|
||||||
const response = await fetch(fetchUrl);
|
const response = await fetch(fetchUrl);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -375,10 +411,10 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
} else if (Array.isArray(folderData)) {
|
} else if (Array.isArray(folderData)) {
|
||||||
folders = folderData;
|
folders = folderData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any global "root" entry.
|
// Remove any global "root" entry.
|
||||||
folders = folders.filter(folder => folder.toLowerCase() !== "root");
|
folders = folders.filter(folder => folder.toLowerCase() !== "root");
|
||||||
|
|
||||||
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
|
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
|
||||||
if (window.userFolderOnly && effectiveRoot !== "root") {
|
if (window.userFolderOnly && effectiveRoot !== "root") {
|
||||||
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
|
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
|
||||||
@@ -386,16 +422,16 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
localStorage.setItem("lastOpenedFolder", effectiveRoot);
|
localStorage.setItem("lastOpenedFolder", effectiveRoot);
|
||||||
window.currentFolder = effectiveRoot;
|
window.currentFolder = effectiveRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||||
|
|
||||||
// Render the folder tree.
|
// Render the folder tree.
|
||||||
const container = document.getElementById("folderTreeContainer");
|
const container = document.getElementById("folderTreeContainer");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
console.error("Folder tree container not found.");
|
console.error("Folder tree container not found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = `<div id="rootRow" class="root-row">
|
let html = `<div id="rootRow" class="root-row">
|
||||||
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
|
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
|
||||||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
|
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
|
||||||
@@ -405,35 +441,35 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
html += renderFolderTree(tree, "", "block");
|
html += renderFolderTree(tree, "", "block");
|
||||||
}
|
}
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Attach drag/drop event listeners.
|
// Attach drag/drop event listeners.
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
container.querySelectorAll(".folder-option").forEach(el => {
|
||||||
el.addEventListener("dragover", folderDragOverHandler);
|
el.addEventListener("dragover", folderDragOverHandler);
|
||||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||||
el.addEventListener("drop", folderDropHandler);
|
el.addEventListener("drop", folderDropHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedFolder) {
|
if (selectedFolder) {
|
||||||
window.currentFolder = selectedFolder;
|
window.currentFolder = selectedFolder;
|
||||||
}
|
}
|
||||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||||
|
|
||||||
const titleEl = document.getElementById("fileListTitle");
|
// Initial breadcrumb update
|
||||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
|
updateBreadcrumbTitle(window.currentFolder);
|
||||||
setupBreadcrumbDelegation();
|
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
|
||||||
const folderState = loadFolderTreeState();
|
const folderState = loadFolderTreeState();
|
||||||
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
|
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
|
||||||
expandTreePath(window.currentFolder);
|
expandTreePath(window.currentFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
||||||
if (selectedEl) {
|
if (selectedEl) {
|
||||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||||
selectedEl.classList.add("selected");
|
selectedEl.classList.add("selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder-option click: update selection, breadcrumbs, and file list
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
container.querySelectorAll(".folder-option").forEach(el => {
|
||||||
el.addEventListener("click", function (e) {
|
el.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -442,13 +478,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
const selected = this.getAttribute("data-folder");
|
const selected = this.getAttribute("data-folder");
|
||||||
window.currentFolder = selected;
|
window.currentFolder = selected;
|
||||||
localStorage.setItem("lastOpenedFolder", selected);
|
localStorage.setItem("lastOpenedFolder", selected);
|
||||||
const titleEl = document.getElementById("fileListTitle");
|
|
||||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(selected) + ")";
|
// Safe breadcrumb update
|
||||||
setupBreadcrumbDelegation();
|
updateBreadcrumbTitle(selected);
|
||||||
loadFileList(selected);
|
loadFileList(selected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Root toggle handler
|
||||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||||
if (rootToggle) {
|
if (rootToggle) {
|
||||||
rootToggle.addEventListener("click", function (e) {
|
rootToggle.addEventListener("click", function (e) {
|
||||||
@@ -471,7 +508,8 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Other folder-toggle handlers
|
||||||
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
||||||
toggle.addEventListener("click", function (e) {
|
toggle.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -494,12 +532,13 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading folder tree:", error);
|
console.error("Error loading folder tree:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// For backward compatibility.
|
// For backward compatibility.
|
||||||
export function loadFolderList(selectedFolder) {
|
export function loadFolderList(selectedFolder) {
|
||||||
loadFolderTree(selectedFolder);
|
loadFolderTree(selectedFolder);
|
||||||
@@ -627,45 +666,53 @@ document.getElementById("cancelCreateFolder").addEventListener("click", function
|
|||||||
document.getElementById("newFolderName").value = "";
|
document.getElementById("newFolderName").value = "";
|
||||||
});
|
});
|
||||||
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
||||||
document.getElementById("submitCreateFolder").addEventListener("click", function () {
|
document.getElementById("submitCreateFolder").addEventListener("click", async () => {
|
||||||
const folderInput = document.getElementById("newFolderName").value.trim();
|
const folderInput = document.getElementById("newFolderName").value.trim();
|
||||||
if (!folderInput) {
|
if (!folderInput) return showToast("Please enter a folder name.");
|
||||||
showToast("Please enter a folder name.");
|
|
||||||
return;
|
const selectedFolder = window.currentFolder || "root";
|
||||||
|
const parent = selectedFolder === "root" ? "" : selectedFolder;
|
||||||
|
|
||||||
|
// 1) Guarantee fresh CSRF
|
||||||
|
try {
|
||||||
|
await loadCsrfToken();
|
||||||
|
} catch {
|
||||||
|
return showToast("Could not refresh CSRF token. Please reload.");
|
||||||
}
|
}
|
||||||
let selectedFolder = window.currentFolder || "root";
|
|
||||||
let fullFolderName = folderInput;
|
// 2) Call with fetchWithCsrf
|
||||||
if (selectedFolder && selectedFolder !== "root") {
|
fetchWithCsrf("/api/folder/createFolder.php", {
|
||||||
fullFolderName = selectedFolder + "/" + folderInput;
|
|
||||||
}
|
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
||||||
fetch("/api/folder/createFolder.php", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ folderName: folderInput, parent })
|
||||||
"X-CSRF-Token": csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
folderName: folderInput,
|
|
||||||
parent: selectedFolder === "root" ? "" : selectedFolder
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(async res => {
|
||||||
.then(data => {
|
if (!res.ok) {
|
||||||
if (data.success) {
|
// pull out a JSON error, or fallback to status text
|
||||||
showToast("Folder created successfully!");
|
let err;
|
||||||
window.currentFolder = fullFolderName;
|
try {
|
||||||
localStorage.setItem("lastOpenedFolder", fullFolderName);
|
const j = await res.json();
|
||||||
loadFolderList(fullFolderName);
|
err = j.error || j.message || res.statusText;
|
||||||
} else {
|
} catch {
|
||||||
showToast("Error: " + (data.error || "Could not create folder"));
|
err = res.statusText;
|
||||||
|
}
|
||||||
|
throw new Error(err);
|
||||||
}
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
showToast("Folder created!");
|
||||||
|
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
||||||
|
window.currentFolder = full;
|
||||||
|
localStorage.setItem("lastOpenedFolder", full);
|
||||||
|
loadFolderList(full);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
showToast("Error creating folder: " + e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
document.getElementById("createFolderModal").style.display = "none";
|
||||||
document.getElementById("newFolderName").value = "";
|
document.getElementById("newFolderName").value = "";
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("Error creating folder:", error);
|
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ const translations = {
|
|||||||
// Dark Mode Toggle
|
// Dark Mode Toggle
|
||||||
"dark_mode_toggle": "Dark Mode",
|
"dark_mode_toggle": "Dark Mode",
|
||||||
"light_mode_toggle": "Light Mode",
|
"light_mode_toggle": "Light Mode",
|
||||||
|
"switch_to_light_mode": "Switch to light mode",
|
||||||
|
"switch_to_dark_mode": "Switch to dark mode",
|
||||||
|
|
||||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||||
"admin_panel": "Admin Panel",
|
"admin_panel": "Admin Panel",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
||||||
import { loadFolderTree } from './folderManager.js';
|
|
||||||
import { initUpload } from './upload.js';
|
import { initUpload } from './upload.js';
|
||||||
import { initAuth, checkAuthentication, loadAdminConfigFunc } from './auth.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 { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
||||||
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
||||||
@@ -12,39 +14,61 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
|
|||||||
import { editFile, saveFile } from './fileEditor.js';
|
import { editFile, saveFile } from './fileEditor.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
|
|
||||||
// Remove the retry logic version and just use loadCsrfToken directly:
|
|
||||||
function loadCsrfToken() {
|
export function loadCsrfToken() {
|
||||||
return fetch('/api/auth/token.php', { credentials: 'include' })
|
return fetchWithCsrf('/api/auth/token.php', {
|
||||||
.then(response => {
|
method: 'GET'
|
||||||
if (!response.ok) {
|
})
|
||||||
throw new Error("Token fetch failed with status: " + response.status);
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Token fetch failed with status ${res.status}`);
|
||||||
}
|
}
|
||||||
return response.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(({ csrf_token, share_url }) => {
|
||||||
window.csrfToken = data.csrf_token;
|
// Update global and <meta>
|
||||||
window.SHARE_URL = data.share_url;
|
window.csrfToken = csrf_token;
|
||||||
|
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
let metaCSRF = document.querySelector('meta[name="csrf-token"]');
|
if (!meta) {
|
||||||
if (!metaCSRF) {
|
meta = document.createElement('meta');
|
||||||
metaCSRF = document.createElement('meta');
|
meta.name = 'csrf-token';
|
||||||
metaCSRF.name = 'csrf-token';
|
document.head.appendChild(meta);
|
||||||
document.head.appendChild(metaCSRF);
|
|
||||||
}
|
}
|
||||||
metaCSRF.setAttribute('content', data.csrf_token);
|
meta.content = csrf_token;
|
||||||
|
|
||||||
let metaShare = document.querySelector('meta[name="share-url"]');
|
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||||
if (!metaShare) {
|
if (!shareMeta) {
|
||||||
metaShare = document.createElement('meta');
|
shareMeta = document.createElement('meta');
|
||||||
metaShare.name = 'share-url';
|
shareMeta.name = 'share-url';
|
||||||
document.head.appendChild(metaShare);
|
document.head.appendChild(shareMeta);
|
||||||
}
|
}
|
||||||
metaShare.setAttribute('content', data.share_url);
|
shareMeta.content = share_url;
|
||||||
|
|
||||||
return data;
|
return { csrf_token, share_url };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) Immediately clear “?logout=1” flag
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('logout') === '1') {
|
||||||
|
localStorage.removeItem("username");
|
||||||
|
localStorage.removeItem("userTOTPEnabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Wire up logoutBtn right away
|
||||||
|
const logoutBtn = document.getElementById("logoutBtn");
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener("click", () => {
|
||||||
|
fetch("/api/auth/logout.php", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
|
})
|
||||||
|
.then(() => window.location.reload(true))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Expose functions for inline handlers.
|
// Expose functions for inline handlers.
|
||||||
window.sendRequest = sendRequest;
|
window.sendRequest = sendRequest;
|
||||||
@@ -115,48 +139,55 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
// --- Dark Mode Persistence ---
|
// --- Dark Mode Persistence ---
|
||||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||||
const storedDarkMode = localStorage.getItem("darkMode");
|
const darkModeIcon = document.getElementById("darkModeIcon");
|
||||||
|
|
||||||
if (storedDarkMode === "true") {
|
if (darkModeToggle && darkModeIcon) {
|
||||||
document.body.classList.add("dark-mode");
|
// 1) Load stored preference (or null)
|
||||||
} else if (storedDarkMode === "false") {
|
let stored = localStorage.getItem("darkMode");
|
||||||
document.body.classList.remove("dark-mode");
|
const hasStored = stored !== null;
|
||||||
} else {
|
|
||||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
// 2) Determine initial mode
|
||||||
document.body.classList.add("dark-mode");
|
const isDark = hasStored
|
||||||
} else {
|
? (stored === "true")
|
||||||
document.body.classList.remove("dark-mode");
|
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
|
||||||
|
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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (darkModeToggle) {
|
updateIcon();
|
||||||
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
|
|
||||||
? t("light_mode")
|
|
||||||
: t("dark_mode");
|
|
||||||
|
|
||||||
darkModeToggle.addEventListener("click", function () {
|
// 4) Click handler: always override and store preference
|
||||||
if (document.body.classList.contains("dark-mode")) {
|
darkModeToggle.addEventListener("click", () => {
|
||||||
document.body.classList.remove("dark-mode");
|
const nowDark = document.body.classList.toggle("dark-mode");
|
||||||
localStorage.setItem("darkMode", "false");
|
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||||
darkModeToggle.textContent = t("dark_mode");
|
updateIcon();
|
||||||
} else {
|
|
||||||
document.body.classList.add("dark-mode");
|
|
||||||
localStorage.setItem("darkMode", "true");
|
|
||||||
darkModeToggle.textContent = t("light_mode");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (localStorage.getItem("darkMode") === null && window.matchMedia) {
|
// 5) OS‐level change: only if no stored pref at load
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => {
|
if (!hasStored && window.matchMedia) {
|
||||||
if (event.matches) {
|
window
|
||||||
document.body.classList.add("dark-mode");
|
.matchMedia("(prefers-color-scheme: dark)")
|
||||||
if (darkModeToggle) darkModeToggle.textContent = t("light_mode");
|
.addEventListener("change", e => {
|
||||||
} else {
|
document.body.classList.toggle("dark-mode", e.matches);
|
||||||
document.body.classList.remove("dark-mode");
|
updateIcon();
|
||||||
if (darkModeToggle) darkModeToggle.textContent = t("dark_mode");
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// --- End Dark Mode Persistence ---
|
// --- End Dark Mode Persistence ---
|
||||||
|
|
||||||
|
|||||||
6
public/js/redoc-init.js
Normal file
6
public/js/redoc-init.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// public/js/redoc-init.js
|
||||||
|
if (!customElements.get('redoc')) {
|
||||||
|
Redoc.init(window.location.origin + '/api.php?spec=1',
|
||||||
|
{},
|
||||||
|
document.getElementById('redoc-container'));
|
||||||
|
}
|
||||||
60
public/js/sharedFolderView.js
Normal file
60
public/js/sharedFolderView.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// sharedFolderView.js
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let viewMode = 'list';
|
||||||
|
const payload = JSON.parse(
|
||||||
|
document.getElementById('shared-data').textContent
|
||||||
|
);
|
||||||
|
const token = payload.token;
|
||||||
|
const filesData = payload.files;
|
||||||
|
const downloadBase = `${window.location.origin}/api/folder/downloadSharedFile.php?token=${encodeURIComponent(token)}&file=`;
|
||||||
|
|
||||||
|
function toggleViewMode() {
|
||||||
|
const listEl = document.getElementById('listViewContainer');
|
||||||
|
const galleryEl = document.getElementById('galleryViewContainer');
|
||||||
|
const btn = document.getElementById('toggleBtn');
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('toggle-btn');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'list') {
|
||||||
|
viewMode = 'gallery';
|
||||||
|
listEl.style.display = 'none';
|
||||||
|
renderGalleryView();
|
||||||
|
galleryEl.style.display = 'block';
|
||||||
|
btn.textContent = 'Switch to List View';
|
||||||
|
} else {
|
||||||
|
viewMode = 'list';
|
||||||
|
galleryEl.style.display = 'none';
|
||||||
|
listEl.style.display = 'block';
|
||||||
|
btn.textContent = 'Switch to Gallery View';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('toggleBtn').addEventListener('click', toggleViewMode);
|
||||||
|
|
||||||
|
function renderGalleryView() {
|
||||||
|
const galleryContainer = document.getElementById('galleryViewContainer');
|
||||||
|
let html = '<div class="shared-gallery-container">';
|
||||||
|
filesData.forEach(file => {
|
||||||
|
const url = downloadBase + encodeURIComponent(file);
|
||||||
|
const ext = file.split('.').pop().toLowerCase();
|
||||||
|
const thumb = /^(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/.test(ext)
|
||||||
|
? `<img src="${url}" alt="${file}">`
|
||||||
|
: `<span class="material-icons">insert_drive_file</span>`;
|
||||||
|
html += `
|
||||||
|
<div class="shared-gallery-card">
|
||||||
|
<div class="gallery-preview" data-url="${url}" style="cursor:pointer;">${thumb}</div>
|
||||||
|
<div class="gallery-info"><span class="gallery-file-name">${file}</span></div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
galleryContainer.innerHTML = html;
|
||||||
|
|
||||||
|
galleryContainer.querySelectorAll('.gallery-preview')
|
||||||
|
.forEach(el => el.addEventListener('click', () => {
|
||||||
|
window.location.href = el.dataset.url;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.renderGalleryView = renderGalleryView;
|
||||||
|
});
|
||||||
@@ -412,7 +412,12 @@ function initResumableUpload() {
|
|||||||
forceChunkSize: true,
|
forceChunkSize: true,
|
||||||
testChunks: false,
|
testChunks: false,
|
||||||
throttleProgressCallbacks: 1,
|
throttleProgressCallbacks: 1,
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
withCredentials: true,
|
||||||
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
|
query: {
|
||||||
|
folder: window.currentFolder || "root",
|
||||||
|
upload_token: window.csrfToken // still as a fallback
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
@@ -496,26 +501,40 @@ function initResumableUpload() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileSuccess", function(file, message) {
|
resumableInstance.on("fileSuccess", function(file, message) {
|
||||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
// Try to parse JSON response
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(message);
|
||||||
|
} catch (e) {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Soft‐fail CSRF? then update token & retry this file
|
||||||
|
if (data && data.csrf_expired) {
|
||||||
|
// Update global and Resumable headers
|
||||||
|
window.csrfToken = data.csrf_token;
|
||||||
|
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
||||||
|
resumableInstance.opts.query.upload_token = data.csrf_token;
|
||||||
|
// Retry this chunk/file
|
||||||
|
file.retry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Otherwise treat as real success:
|
||||||
|
const li = document.querySelector(
|
||||||
|
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
||||||
|
);
|
||||||
if (li && li.progressBar) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerText = "Done";
|
li.progressBar.innerText = "Done";
|
||||||
// Hide pause/resume and remove buttons for successful files.
|
// remove action buttons
|
||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) {
|
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
||||||
pauseResumeBtn.style.display = "none";
|
|
||||||
}
|
|
||||||
const removeBtn = li.querySelector(".remove-file-btn");
|
const removeBtn = li.querySelector(".remove-file-btn");
|
||||||
if (removeBtn) {
|
if (removeBtn) removeBtn.style.display = "none";
|
||||||
removeBtn.style.display = "none";
|
setTimeout(() => li.remove(), 5000);
|
||||||
}
|
|
||||||
// Schedule removal of the file entry after 5 seconds.
|
|
||||||
setTimeout(() => {
|
|
||||||
li.remove();
|
|
||||||
window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier);
|
|
||||||
updateFileInfoCount();
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -618,8 +637,25 @@ function submitFiles(allFiles) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
jsonResponse = null;
|
jsonResponse = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
||||||
|
if (jsonResponse && jsonResponse.csrf_expired) {
|
||||||
|
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
||||||
|
// 1) update global token + header
|
||||||
|
window.csrfToken = jsonResponse.csrf_token;
|
||||||
|
xhr.open("POST", "/api/upload/upload.php", true);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
|
||||||
|
// 2) re-send the same formData
|
||||||
|
xhr.send(formData);
|
||||||
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||||
|
// real success
|
||||||
if (li) {
|
if (li) {
|
||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerText = "Done";
|
li.progressBar.innerText = "Done";
|
||||||
@@ -627,11 +663,14 @@ function submitFiles(allFiles) {
|
|||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = true;
|
uploadResults[file.uploadIndex] = true;
|
||||||
} else {
|
} else {
|
||||||
|
// real failure
|
||||||
if (li) {
|
if (li) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
allSucceeded = false;
|
allSucceeded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Only now count this chunk as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
@@ -665,6 +704,7 @@ function submitFiles(allFiles) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
xhr.open("POST", "/api/upload/upload.php", true);
|
xhr.open("POST", "/api/upload/upload.php", true);
|
||||||
|
xhr.withCredentials = true;
|
||||||
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
|
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/webdav.php
|
// public/webdav.php
|
||||||
|
|
||||||
|
// ─── 0) Forward Basic auth into PHP_AUTH_* for every HTTP verb ─────────────
|
||||||
if (
|
if (
|
||||||
empty($_SERVER['PHP_AUTH_USER'])
|
empty($_SERVER['PHP_AUTH_USER'])
|
||||||
&& !empty($_SERVER['HTTP_AUTHORIZATION'])
|
&& !empty($_SERVER['HTTP_AUTHORIZATION'])
|
||||||
@@ -11,46 +12,58 @@ if (
|
|||||||
$_SERVER['PHP_AUTH_PW'] = $p;
|
$_SERVER['PHP_AUTH_PW'] = $p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
||||||
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
||||||
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
||||||
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
|
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
|
||||||
|
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
|
||||||
|
|
||||||
// ─── 3) Load your WebDAV directory implementation ──────────────────────────
|
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
||||||
|
$adminConfig = AdminModel::getConfig();
|
||||||
|
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
||||||
|
if (!$enableWebDAV) {
|
||||||
|
header('HTTP/1.1 403 Forbidden');
|
||||||
|
echo 'WebDAV access is currently disabled by administrator.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2) Load WebDAV directory implementation ──────────────────────────
|
||||||
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
||||||
use Sabre\DAV\Server;
|
use Sabre\DAV\Server;
|
||||||
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
||||||
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
||||||
use Sabre\DAV\Browser\Plugin as BrowserPlugin;
|
|
||||||
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
||||||
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
||||||
use FileRise\WebDAV\FileRiseDirectory;
|
use FileRise\WebDAV\FileRiseDirectory;
|
||||||
|
|
||||||
|
// ─── 3) HTTP‑Basic backend ─────────────────────────────────────────────────
|
||||||
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
||||||
return \AuthModel::authenticate($user, $pass) !== false;
|
return \AuthModel::authenticate($user, $pass) !== false;
|
||||||
});
|
});
|
||||||
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
||||||
|
|
||||||
|
// ─── 4) Determine user scope ────────────────────────────────────────────────
|
||||||
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||||
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
||||||
|
|
||||||
if ($isAdmin || !$folderOnly) {
|
if ($isAdmin || !$folderOnly) {
|
||||||
// admins or unrestricted users see the full /uploads
|
// Admins (or users without folder-only restriction) see the full /uploads
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
||||||
} else {
|
} else {
|
||||||
// folder‑only users see only /uploads/{username}
|
// Folder‑only users see only /uploads/{username}
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
|
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
|
||||||
if (!is_dir($rootPath)) {
|
if (!is_dir($rootPath)) {
|
||||||
mkdir($rootPath, 0755, true);
|
mkdir($rootPath, 0755, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
|
||||||
$server = new Server([
|
$server = new Server([
|
||||||
new FileRiseDirectory($rootPath, $user, $folderOnly),
|
new FileRiseDirectory($rootPath, $user, $folderOnly),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$server->addPlugin($authPlugin);
|
$server->addPlugin($authPlugin);
|
||||||
//$server->addPlugin(new BrowserPlugin()); // optional HTML browser UI
|
|
||||||
$server->addPlugin(
|
$server->addPlugin(
|
||||||
new LocksPlugin(
|
new LocksPlugin(
|
||||||
new LocksFileBackend(sys_get_temp_dir() . '/sabre-locksdb')
|
new LocksFileBackend(sys_get_temp_dir() . '/sabre-locksdb')
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ class AdminController
|
|||||||
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
||||||
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
||||||
* ),
|
* ),
|
||||||
* @OA\Property(property="globalOtpauthUrl", type="string", example="")
|
* @OA\Property(property="globalOtpauthUrl", type="string", example=""),
|
||||||
|
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="sharedMaxUploadSize", type="integer", example=52428800)
|
||||||
* )
|
* )
|
||||||
* ),
|
* ),
|
||||||
* @OA\Response(
|
* @OA\Response(
|
||||||
@@ -88,7 +90,9 @@ class AdminController
|
|||||||
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
||||||
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
||||||
* ),
|
* ),
|
||||||
* @OA\Property(property="globalOtpauthUrl", type="string", example="")
|
* @OA\Property(property="globalOtpauthUrl", type="string", example=""),
|
||||||
|
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="sharedMaxUploadSize", type="integer", example=52428800)
|
||||||
* )
|
* )
|
||||||
* ),
|
* ),
|
||||||
* @OA\Response(
|
* @OA\Response(
|
||||||
@@ -149,7 +153,7 @@ class AdminController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare configuration array.
|
// Prepare existing settings
|
||||||
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
|
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
|
||||||
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
|
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
|
||||||
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
|
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
|
||||||
@@ -183,20 +187,38 @@ class AdminController
|
|||||||
}
|
}
|
||||||
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
|
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
|
||||||
|
|
||||||
|
// ── NEW: enableWebDAV flag ──────────────────────────────────────
|
||||||
|
$enableWebDAV = false;
|
||||||
|
if (array_key_exists('enableWebDAV', $data)) {
|
||||||
|
$enableWebDAV = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
||||||
|
} elseif (isset($data['features']['enableWebDAV'])) {
|
||||||
|
$enableWebDAV = filter_var($data['features']['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NEW: sharedMaxUploadSize ──────────────────────────────────────
|
||||||
|
$sharedMaxUploadSize = null;
|
||||||
|
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
||||||
|
$sharedMaxUploadSize = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
||||||
|
} elseif (isset($data['features']['sharedMaxUploadSize'])) {
|
||||||
|
$sharedMaxUploadSize = filter_var($data['features']['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
||||||
|
}
|
||||||
|
|
||||||
$configUpdate = [
|
$configUpdate = [
|
||||||
'header_title' => $headerTitle,
|
'header_title' => $headerTitle,
|
||||||
'oidc' => [
|
'oidc' => [
|
||||||
'providerUrl' => $oidcProviderUrl,
|
'providerUrl' => $oidcProviderUrl,
|
||||||
'clientId' => $oidcClientId,
|
'clientId' => $oidcClientId,
|
||||||
'clientSecret' => $oidcClientSecret,
|
'clientSecret' => $oidcClientSecret,
|
||||||
'redirectUri' => $oidcRedirectUri,
|
'redirectUri' => $oidcRedirectUri,
|
||||||
],
|
],
|
||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
'disableFormLogin' => $disableFormLogin,
|
'disableFormLogin' => $disableFormLogin,
|
||||||
'disableBasicAuth' => $disableBasicAuth,
|
'disableBasicAuth' => $disableBasicAuth,
|
||||||
'disableOIDCLogin' => $disableOIDCLogin,
|
'disableOIDCLogin' => $disableOIDCLogin,
|
||||||
],
|
],
|
||||||
'globalOtpauthUrl' => $globalOtpauthUrl
|
'globalOtpauthUrl' => $globalOtpauthUrl,
|
||||||
|
'enableWebDAV' => $enableWebDAV,
|
||||||
|
'sharedMaxUploadSize' => $sharedMaxUploadSize // ← NEW
|
||||||
];
|
];
|
||||||
|
|
||||||
// Delegate to the model.
|
// Delegate to the model.
|
||||||
@@ -207,4 +229,4 @@ class AdminController
|
|||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,28 +238,28 @@ class AuthController
|
|||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
$expiry = time() + 30 * 24 * 60 * 60;
|
$expiry = time() + 30 * 24 * 60 * 60;
|
||||||
$all = [];
|
$all = [];
|
||||||
|
|
||||||
if (file_exists($tokFile)) {
|
if (file_exists($tokFile)) {
|
||||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||||
$all = json_decode($dec, true) ?: [];
|
$all = json_decode($dec, true) ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$all[$token] = [
|
$all[$token] = [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'expiry' => $expiry,
|
'expiry' => $expiry,
|
||||||
'isAdmin' => $_SESSION['isAdmin']
|
'isAdmin' => $_SESSION['isAdmin']
|
||||||
];
|
];
|
||||||
|
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$tokFile,
|
$tokFile,
|
||||||
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
||||||
LOCK_EX
|
LOCK_EX
|
||||||
);
|
);
|
||||||
|
|
||||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
|
||||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||||
|
|
||||||
setcookie(
|
setcookie(
|
||||||
session_name(),
|
session_name(),
|
||||||
session_id(),
|
session_id(),
|
||||||
@@ -269,7 +269,7 @@ class AuthController
|
|||||||
$secure,
|
$secure,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,40 +341,86 @@ class AuthController
|
|||||||
|
|
||||||
public function checkAuth(): void
|
public function checkAuth(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
// 1) Remember-me re-login
|
||||||
|
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
|
||||||
|
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
|
||||||
|
if ($payload) {
|
||||||
|
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['csrf_token'] = $old;
|
||||||
|
$_SESSION['authenticated'] = true;
|
||||||
|
$_SESSION['username'] = $payload['username'];
|
||||||
|
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
|
||||||
|
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
|
||||||
|
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
|
||||||
|
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
|
||||||
|
// regenerate CSRF if you use one
|
||||||
|
|
||||||
|
|
||||||
|
// TOTP enabled? (same logic as below)
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
$totp = false;
|
||||||
|
if (file_exists($usersFile)) {
|
||||||
|
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
$parts = explode(':', trim($line));
|
||||||
|
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
|
||||||
|
$totp = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'authenticated' => true,
|
||||||
|
'csrf_token' => $_SESSION['csrf_token'],
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'],
|
||||||
|
'totp_enabled' => $totp,
|
||||||
|
'username' => $_SESSION['username'],
|
||||||
|
'folderOnly' => $_SESSION['folderOnly'],
|
||||||
|
'readOnly' => $_SESSION['readOnly'],
|
||||||
|
'disableUpload' => $_SESSION['disableUpload']
|
||||||
|
]);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
// setup mode?
|
// 2) Setup mode?
|
||||||
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
|
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
|
||||||
error_log("checkAuth: setup mode");
|
error_log("checkAuth: setup mode");
|
||||||
echo json_encode(['setup' => true]);
|
echo json_encode(['setup' => true]);
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) Session-based auth
|
||||||
if (empty($_SESSION['authenticated'])) {
|
if (empty($_SESSION['authenticated'])) {
|
||||||
echo json_encode(['authenticated' => false]);
|
echo json_encode(['authenticated' => false]);
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOTP enabled?
|
// 4) TOTP enabled?
|
||||||
$totp = false;
|
$totp = false;
|
||||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
|
if ($parts[0] === ($_SESSION['username'] ?? '') && !empty($parts[3])) {
|
||||||
$totp = true;
|
$totp = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$isAdmin = ((int)AuthModel::getUserRole($_SESSION['username']) === 1);
|
// 5) Final response
|
||||||
$resp = [
|
$resp = [
|
||||||
'authenticated' => true,
|
'authenticated' => true,
|
||||||
'isAdmin' => $isAdmin,
|
'isAdmin' => !empty($_SESSION['isAdmin']),
|
||||||
'totp_enabled' => $totp,
|
'totp_enabled' => $totp,
|
||||||
'username' => $_SESSION['username'],
|
'username' => $_SESSION['username'],
|
||||||
'folderOnly' => $_SESSION['folderOnly'] ?? false,
|
'folderOnly' => $_SESSION['folderOnly'] ?? false,
|
||||||
'readOnly' => $_SESSION['readOnly'] ?? false,
|
'readOnly' => $_SESSION['readOnly'] ?? false,
|
||||||
'disableUpload' => $_SESSION['disableUpload'] ?? false
|
'disableUpload' => $_SESSION['disableUpload'] ?? false
|
||||||
];
|
];
|
||||||
|
|
||||||
echo json_encode($resp);
|
echo json_encode($resp);
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
@@ -403,10 +449,19 @@ class AuthController
|
|||||||
*/
|
*/
|
||||||
public function getToken(): void
|
public function getToken(): void
|
||||||
{
|
{
|
||||||
|
// 1) Ensure session and CSRF token exist
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Emit headers
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
||||||
|
|
||||||
|
// 3) Return JSON payload
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
"csrf_token" => $_SESSION['csrf_token'],
|
'csrf_token' => $_SESSION['csrf_token'],
|
||||||
"share_url" => SHARE_URL
|
'share_url' => SHARE_URL
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,20 @@ class FolderController
|
|||||||
*
|
*
|
||||||
* @return void Outputs HTML content.
|
* @return void Outputs HTML content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function formatBytes($bytes)
|
||||||
|
{
|
||||||
|
if ($bytes < 1024) {
|
||||||
|
return $bytes . " B";
|
||||||
|
} elseif ($bytes < 1024 * 1024) {
|
||||||
|
return round($bytes / 1024, 2) . " KB";
|
||||||
|
} elseif ($bytes < 1024 * 1024 * 1024) {
|
||||||
|
return round($bytes / (1024 * 1024), 2) . " MB";
|
||||||
|
} else {
|
||||||
|
return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function shareFolder(): void
|
public function shareFolder(): void
|
||||||
{
|
{
|
||||||
// Retrieve GET parameters.
|
// Retrieve GET parameters.
|
||||||
@@ -495,12 +509,14 @@ class FolderController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract data for the HTML view.
|
// Load admin config so we can pull the sharedMaxUploadSize
|
||||||
$folderName = $data['folder'];
|
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||||
$files = $data['files'];
|
$adminConfig = AdminModel::getConfig();
|
||||||
$currentPage = $data['currentPage'];
|
$sharedMaxUploadSize = isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize'])
|
||||||
$totalPages = $data['totalPages'];
|
? (int)$adminConfig['sharedMaxUploadSize']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// For human‐readable formatting
|
||||||
function formatBytes($bytes)
|
function formatBytes($bytes)
|
||||||
{
|
{
|
||||||
if ($bytes < 1024) {
|
if ($bytes < 1024) {
|
||||||
@@ -514,6 +530,12 @@ class FolderController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract data for the HTML view.
|
||||||
|
$folderName = $data['folder'];
|
||||||
|
$files = $data['files'];
|
||||||
|
$currentPage = $data['currentPage'];
|
||||||
|
$totalPages = $data['totalPages'];
|
||||||
|
|
||||||
// Build the HTML view.
|
// Build the HTML view.
|
||||||
header("Content-Type: text/html; charset=utf-8");
|
header("Content-Type: text/html; charset=utf-8");
|
||||||
?>
|
?>
|
||||||
@@ -528,13 +550,18 @@ class FolderController
|
|||||||
body {
|
body {
|
||||||
background: #f2f2f2;
|
background: #f2f2f2;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
padding: 20px;
|
padding: 0px 20px 20px 20px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -639,6 +666,28 @@ class FolderController
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #777;
|
color: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
background-color: #007BFF;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -648,7 +697,7 @@ class FolderController
|
|||||||
</div>
|
</div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Toggle Button -->
|
<!-- Toggle Button -->
|
||||||
<button id="toggleBtn" class="toggle-btn" onclick="toggleViewMode()">Switch to Gallery View</button>
|
<button id="toggleBtn" class="toggle-btn">Switch to Gallery View</button>
|
||||||
|
|
||||||
<!-- List View Container -->
|
<!-- List View Container -->
|
||||||
<div id="listViewContainer">
|
<div id="listViewContainer">
|
||||||
@@ -717,7 +766,11 @@ class FolderController
|
|||||||
<!-- Upload Container (if uploads are allowed by the share record) -->
|
<!-- Upload Container (if uploads are allowed by the share record) -->
|
||||||
<?php if (isset($data['record']['allowUpload']) && $data['record']['allowUpload'] == 1): ?>
|
<?php if (isset($data['record']['allowUpload']) && $data['record']['allowUpload'] == 1): ?>
|
||||||
<div class="upload-container">
|
<div class="upload-container">
|
||||||
<h3>Upload File (50mb max size)</h3>
|
<h3>Upload File
|
||||||
|
<?php if ($sharedMaxUploadSize !== null): ?>
|
||||||
|
(<?php echo formatBytes($sharedMaxUploadSize); ?> max size)
|
||||||
|
<?php endif; ?>
|
||||||
|
</h3>
|
||||||
<form action="/api/folder/uploadToSharedFolder.php" method="post" enctype="multipart/form-data">
|
<form action="/api/folder/uploadToSharedFolder.php" method="post" enctype="multipart/form-data">
|
||||||
<!-- Pass the share token so the upload endpoint can verify -->
|
<!-- Pass the share token so the upload endpoint can verify -->
|
||||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
@@ -731,75 +784,14 @@ class FolderController
|
|||||||
<div class="footer">
|
<div class="footer">
|
||||||
© <?php echo date("Y"); ?> FileRise. All rights reserved.
|
© <?php echo date("Y"); ?> FileRise. All rights reserved.
|
||||||
</div>
|
</div>
|
||||||
|
<!-- non-executing JSON payload, never blocked by CSP -->
|
||||||
<script>
|
<script type="application/json" id="shared-data">
|
||||||
// (Optional) JavaScript for toggling view modes (list/gallery).
|
{
|
||||||
var viewMode = 'list';
|
"token": <?php echo json_encode($token, JSON_HEX_TAG); ?>,
|
||||||
window.imageCache = window.imageCache || {};
|
"files": <?php echo json_encode($files, JSON_HEX_TAG); ?>
|
||||||
var filesData = <?php echo json_encode($files); ?>;
|
|
||||||
|
|
||||||
// Use the shared‑folder relative path (from your model), not realFolderPath
|
|
||||||
// $data['folder'] should be something like "eafwef/testfolder2/test/new folder two"
|
|
||||||
var rawRelPath = "<?php echo addslashes($data['folder']); ?>";
|
|
||||||
// Split into segments, encode each segment, then re-join
|
|
||||||
var folderSegments = rawRelPath
|
|
||||||
.split('/')
|
|
||||||
.map(encodeURIComponent)
|
|
||||||
.join('/');
|
|
||||||
|
|
||||||
function renderGalleryView() {
|
|
||||||
var galleryContainer = document.getElementById("galleryViewContainer");
|
|
||||||
var html = '<div class="shared-gallery-container">';
|
|
||||||
filesData.forEach(function(file) {
|
|
||||||
// Encode the filename too
|
|
||||||
var fileName = encodeURIComponent(file);
|
|
||||||
var fileUrl = window.location.origin +
|
|
||||||
'/uploads/' +
|
|
||||||
folderSegments +
|
|
||||||
'/' +
|
|
||||||
fileName +
|
|
||||||
'?t=' +
|
|
||||||
Date.now();
|
|
||||||
|
|
||||||
var ext = file.split('.').pop().toLowerCase();
|
|
||||||
var thumbnail;
|
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].indexOf(ext) >= 0) {
|
|
||||||
thumbnail = '<img src="' + fileUrl + '" alt="' + file + '">';
|
|
||||||
} else {
|
|
||||||
thumbnail = '<span class="material-icons">insert_drive_file</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html +=
|
|
||||||
'<div class="shared-gallery-card">' +
|
|
||||||
'<div class="gallery-preview" ' +
|
|
||||||
'onclick="window.location.href=\'' + fileUrl + '\'" ' +
|
|
||||||
'style="cursor:pointer;">' +
|
|
||||||
thumbnail +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="gallery-info">' +
|
|
||||||
'<span class="gallery-file-name">' + file + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
galleryContainer.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleViewMode() {
|
|
||||||
if (viewMode === 'list') {
|
|
||||||
viewMode = 'gallery';
|
|
||||||
document.getElementById("listViewContainer").style.display = "none";
|
|
||||||
renderGalleryView();
|
|
||||||
document.getElementById("galleryViewContainer").style.display = "block";
|
|
||||||
document.getElementById("toggleBtn").textContent = "Switch to List View";
|
|
||||||
} else {
|
|
||||||
viewMode = 'list';
|
|
||||||
document.getElementById("galleryViewContainer").style.display = "none";
|
|
||||||
document.getElementById("listViewContainer").style.display = "block";
|
|
||||||
document.getElementById("toggleBtn").textContent = "Switch to Gallery View";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/sharedFolderView.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -72,34 +72,56 @@ class UploadController {
|
|||||||
*/
|
*/
|
||||||
public function handleUpload(): void {
|
public function handleUpload(): void {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// CSRF Protection.
|
//
|
||||||
|
// 1) CSRF – pull from header or POST fields
|
||||||
|
//
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$receivedToken = $headersArr['x-csrf-token'] ?? '';
|
$received = '';
|
||||||
if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) {
|
if (!empty($headersArr['x-csrf-token'])) {
|
||||||
http_response_code(403);
|
$received = trim($headersArr['x-csrf-token']);
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
} elseif (!empty($_POST['csrf_token'])) {
|
||||||
|
$received = trim($_POST['csrf_token']);
|
||||||
|
} elseif (!empty($_POST['upload_token'])) {
|
||||||
|
$received = trim($_POST['upload_token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1a) If it doesn’t match, soft-fail: send new token and let client retry
|
||||||
|
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
||||||
|
// regenerate
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
// tell client “please retry with this new token”
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'csrf_expired' => true,
|
||||||
|
'csrf_token' => $_SESSION['csrf_token']
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
// Ensure user is authenticated.
|
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
//
|
||||||
|
// 2) Auth checks
|
||||||
|
//
|
||||||
|
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
echo json_encode(["error" => "Unauthorized"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
// Check user permissions.
|
$userPerms = loadUserPermissions($_SESSION['username']);
|
||||||
$username = $_SESSION['username'] ?? '';
|
if (!empty($userPerms['disableUpload'])) {
|
||||||
$userPermissions = loadUserPermissions($username);
|
|
||||||
if ($username && !empty($userPermissions['disableUpload'])) {
|
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(["error" => "Upload disabled for this user."]);
|
echo json_encode(["error" => "Upload disabled for this user."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to the model.
|
//
|
||||||
|
// 3) Delegate the actual file handling
|
||||||
|
//
|
||||||
$result = UploadModel::handleUpload($_POST, $_FILES);
|
$result = UploadModel::handleUpload($_POST, $_FILES);
|
||||||
|
|
||||||
// For chunked uploads, output JSON (e.g., "chunk uploaded" status).
|
//
|
||||||
|
// 4) Respond
|
||||||
|
//
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
@@ -109,8 +131,8 @@ class UploadController {
|
|||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, for full upload success, set a flash message and redirect.
|
// full‐upload redirect
|
||||||
$_SESSION['upload_message'] = "File uploaded successfully.";
|
$_SESSION['upload_message'] = "File uploaded successfully.";
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,63 +87,83 @@ class UserController
|
|||||||
|
|
||||||
public function addUser()
|
public function addUser()
|
||||||
{
|
{
|
||||||
|
// 1) Ensure JSON output and session
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
// 1a) Initialize CSRF token if missing
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if we're in setup mode.
|
// 2) Determine setup mode (first-ever admin creation)
|
||||||
// Setup mode means the "setup" query parameter is passed
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
// and users.txt is missing, empty, or contains only whitespace.
|
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
||||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
$setupMode = false;
|
||||||
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) {
|
if (
|
||||||
// Allow initial admin creation without session or CSRF checks.
|
$isSetup && (! file_exists($usersFile)
|
||||||
|
|| filesize($usersFile) === 0
|
||||||
|
|| trim(file_get_contents($usersFile)) === ''
|
||||||
|
)
|
||||||
|
) {
|
||||||
$setupMode = true;
|
$setupMode = true;
|
||||||
} else {
|
} else {
|
||||||
$setupMode = false;
|
// 3) In non-setup, enforce CSRF + auth checks
|
||||||
// In non-setup mode, perform CSRF token and authentication checks.
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
||||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
// 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
|
||||||
http_response_code(403);
|
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
||||||
|
echo json_encode([
|
||||||
|
'csrf_expired' => true,
|
||||||
|
'csrf_token' => $_SESSION['csrf_token']
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b) Must be logged in as admin
|
||||||
if (
|
if (
|
||||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
empty($_SESSION['authenticated'])
|
||||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|| $_SESSION['authenticated'] !== true
|
||||||
|
|| empty($_SESSION['isAdmin'])
|
||||||
|
|| $_SESSION['isAdmin'] !== true
|
||||||
) {
|
) {
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
echo json_encode(["error" => "Unauthorized"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the JSON input data.
|
// 4) Parse input
|
||||||
$data = json_decode(file_get_contents("php://input"), true);
|
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$newUsername = trim($data["username"] ?? "");
|
$newUsername = trim($data['username'] ?? '');
|
||||||
$newPassword = trim($data["password"] ?? "");
|
$newPassword = trim($data['password'] ?? '');
|
||||||
|
|
||||||
// In setup mode, force the new user to be an admin.
|
// 5) Determine admin flag
|
||||||
if ($setupMode) {
|
if ($setupMode) {
|
||||||
$isAdmin = "1";
|
$isAdmin = '1';
|
||||||
} else {
|
} else {
|
||||||
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0";
|
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that a username and password are provided.
|
// 6) Validate fields
|
||||||
if (!$newUsername || !$newPassword) {
|
if ($newUsername === '' || $newPassword === '') {
|
||||||
echo json_encode(["error" => "Username and password required"]);
|
echo json_encode(["error" => "Username and password required"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate username format.
|
|
||||||
if (!preg_match(REGEX_USER, $newUsername)) {
|
if (!preg_match(REGEX_USER, $newUsername)) {
|
||||||
echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
echo json_encode([
|
||||||
|
"error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate the business logic to the model.
|
// 7) Delegate to model
|
||||||
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
||||||
|
|
||||||
|
// 8) Return model result
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -852,7 +872,7 @@ class UserController
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||||
|
|
||||||
// Rate‑limit
|
// Rate-limit
|
||||||
if (!isset($_SESSION['totp_failures'])) {
|
if (!isset($_SESSION['totp_failures'])) {
|
||||||
$_SESSION['totp_failures'] = 0;
|
$_SESSION['totp_failures'] = 0;
|
||||||
}
|
}
|
||||||
@@ -863,7 +883,7 @@ class UserController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Must be authenticated OR pending login
|
// Must be authenticated OR pending login
|
||||||
if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) {
|
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
@@ -878,7 +898,7 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate input
|
// Parse & validate input
|
||||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
$inputData = json_decode(file_get_contents("php://input"), true);
|
||||||
$code = trim($inputData['totp_code'] ?? '');
|
$code = trim($inputData['totp_code'] ?? '');
|
||||||
if (!preg_match('/^\d{6}$/', $code)) {
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
@@ -893,11 +913,11 @@ class UserController
|
|||||||
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
|
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pending‑login flow (first password step passed)
|
// === Pending-login flow (we just came from auth and need to finish login) ===
|
||||||
if (isset($_SESSION['pending_login_user'])) {
|
if (isset($_SESSION['pending_login_user'])) {
|
||||||
$username = $_SESSION['pending_login_user'];
|
$username = $_SESSION['pending_login_user'];
|
||||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
$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)) {
|
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
||||||
$_SESSION['totp_failures']++;
|
$_SESSION['totp_failures']++;
|
||||||
@@ -906,53 +926,45 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Issue “remember me” token if requested ===
|
// Issue “remember me” token if requested
|
||||||
if ($rememberMe) {
|
if ($rememberMe) {
|
||||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
$expiry = time() + 30 * 24 * 60 * 60;
|
$expiry = time() + 30 * 24 * 60 * 60;
|
||||||
$all = [];
|
$all = [];
|
||||||
|
|
||||||
if (file_exists($tokFile)) {
|
if (file_exists($tokFile)) {
|
||||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||||
$all = json_decode($dec, true) ?: [];
|
$all = json_decode($dec, true) ?: [];
|
||||||
}
|
}
|
||||||
$all[$token] = [
|
$all[$token] = [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'expiry' => $expiry,
|
'expiry' => $expiry,
|
||||||
'isAdmin' => $_SESSION['isAdmin']
|
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
||||||
|
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
||||||
|
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
||||||
|
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false
|
||||||
];
|
];
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$tokFile,
|
$tokFile,
|
||||||
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
||||||
LOCK_EX
|
LOCK_EX
|
||||||
);
|
);
|
||||||
|
|
||||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
|
||||||
// Persistent cookie
|
|
||||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||||
|
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
||||||
// Re‑issue PHP session cookie
|
|
||||||
setcookie(
|
|
||||||
session_name(),
|
|
||||||
session_id(),
|
|
||||||
$expiry,
|
|
||||||
'/',
|
|
||||||
'',
|
|
||||||
$secure,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize login
|
// === Finalize login into session exactly as finalizeLogin() would ===
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['authenticated'] = true;
|
$_SESSION['authenticated'] = true;
|
||||||
$_SESSION['username'] = $username;
|
$_SESSION['username'] = $username;
|
||||||
$_SESSION['isAdmin'] = (userModel::getUserRole($username) === "1");
|
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
||||||
$_SESSION['folderOnly'] = loadUserPermissions($username);
|
$perms = loadUserPermissions($username);
|
||||||
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||||
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||||
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
|
|
||||||
// Clean up
|
// Clean up pending markers
|
||||||
unset(
|
unset(
|
||||||
$_SESSION['pending_login_user'],
|
$_SESSION['pending_login_user'],
|
||||||
$_SESSION['pending_login_secret'],
|
$_SESSION['pending_login_secret'],
|
||||||
@@ -960,34 +972,43 @@ class UserController
|
|||||||
$_SESSION['totp_failures']
|
$_SESSION['totp_failures']
|
||||||
);
|
);
|
||||||
|
|
||||||
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
|
// Send back full login payload
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'ok',
|
||||||
|
'success' => 'Login successful',
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'],
|
||||||
|
'folderOnly' => $_SESSION['folderOnly'],
|
||||||
|
'readOnly' => $_SESSION['readOnly'],
|
||||||
|
'disableUpload' => $_SESSION['disableUpload'],
|
||||||
|
'username' => $_SESSION['username']
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup/verification flow (not pending)
|
// Setup/verification flow (not pending)
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (!$username) {
|
if (!$username) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$totpSecret = userModel::getTOTPSecret($username);
|
$totpSecret = userModel::getTOTPSecret($username);
|
||||||
if (!$totpSecret) {
|
if (!$totpSecret) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
|
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$tfa->verifyCode($totpSecret, $code)) {
|
if (!$tfa->verifyCode($totpSecret, $code)) {
|
||||||
$_SESSION['totp_failures']++;
|
$_SESSION['totp_failures']++;
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful setup/verification
|
// Successful setup/verification
|
||||||
unset($_SESSION['totp_failures']);
|
unset($_SESSION['totp_failures']);
|
||||||
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ require_once PROJECT_ROOT . '/config/config.php';
|
|||||||
|
|
||||||
class AdminModel
|
class AdminModel
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes.
|
||||||
|
*
|
||||||
|
* @param string $val
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the admin configuration file.
|
* Updates the admin configuration file.
|
||||||
@@ -24,6 +41,28 @@ class AdminModel
|
|||||||
return ["error" => "Incomplete OIDC configuration."];
|
return ["error" => "Incomplete OIDC configuration."];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure enableWebDAV flag is boolean (default to false if missing)
|
||||||
|
$configUpdate['enableWebDAV'] = isset($configUpdate['enableWebDAV'])
|
||||||
|
? (bool)$configUpdate['enableWebDAV']
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Validate sharedMaxUploadSize if provided
|
||||||
|
if (isset($configUpdate['sharedMaxUploadSize'])) {
|
||||||
|
$sms = filter_var(
|
||||||
|
$configUpdate['sharedMaxUploadSize'],
|
||||||
|
FILTER_VALIDATE_INT,
|
||||||
|
["options" => ["min_range" => 1]]
|
||||||
|
);
|
||||||
|
if ($sms === false) {
|
||||||
|
return ["error" => "Invalid sharedMaxUploadSize."];
|
||||||
|
}
|
||||||
|
$totalBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
|
||||||
|
if ($sms > $totalBytes) {
|
||||||
|
return ["error" => "sharedMaxUploadSize must be ≤ TOTAL_UPLOAD_SIZE."];
|
||||||
|
}
|
||||||
|
$configUpdate['sharedMaxUploadSize'] = $sms;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert configuration to JSON.
|
// Convert configuration to JSON.
|
||||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||||
if ($plainTextConfig === false) {
|
if ($plainTextConfig === false) {
|
||||||
@@ -59,7 +98,8 @@ class AdminModel
|
|||||||
*
|
*
|
||||||
* @return array The configuration array, or defaults if not found.
|
* @return array The configuration array, or defaults if not found.
|
||||||
*/
|
*/
|
||||||
public static function getConfig(): array {
|
public static function getConfig(): array
|
||||||
|
{
|
||||||
$configFile = USERS_DIR . 'adminConfig.json';
|
$configFile = USERS_DIR . 'adminConfig.json';
|
||||||
if (file_exists($configFile)) {
|
if (file_exists($configFile)) {
|
||||||
$encryptedContent = file_get_contents($configFile);
|
$encryptedContent = file_get_contents($configFile);
|
||||||
@@ -72,10 +112,9 @@ class AdminModel
|
|||||||
if (!is_array($config)) {
|
if (!is_array($config)) {
|
||||||
$config = [];
|
$config = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize login options.
|
// Normalize login options if missing
|
||||||
if (!isset($config['loginOptions'])) {
|
if (!isset($config['loginOptions'])) {
|
||||||
// Create loginOptions array from top-level keys if missing.
|
|
||||||
$config['loginOptions'] = [
|
$config['loginOptions'] = [
|
||||||
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
||||||
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
||||||
@@ -88,31 +127,43 @@ class AdminModel
|
|||||||
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
|
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
|
||||||
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
|
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default values for other keys
|
||||||
if (!isset($config['globalOtpauthUrl'])) {
|
if (!isset($config['globalOtpauthUrl'])) {
|
||||||
$config['globalOtpauthUrl'] = "";
|
$config['globalOtpauthUrl'] = "";
|
||||||
}
|
}
|
||||||
if (!isset($config['header_title']) || empty($config['header_title'])) {
|
if (!isset($config['header_title']) || empty($config['header_title'])) {
|
||||||
$config['header_title'] = "FileRise";
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
} else {
|
} else {
|
||||||
// Return defaults.
|
// Return defaults.
|
||||||
return [
|
return [
|
||||||
'header_title' => "FileRise",
|
'header_title' => "FileRise",
|
||||||
'oidc' => [
|
'oidc' => [
|
||||||
'providerUrl' => 'https://your-oidc-provider.com',
|
'providerUrl' => 'https://your-oidc-provider.com',
|
||||||
'clientId' => 'YOUR_CLIENT_ID',
|
'clientId' => 'YOUR_CLIENT_ID',
|
||||||
'clientSecret' => 'YOUR_CLIENT_SECRET',
|
'clientSecret' => 'YOUR_CLIENT_SECRET',
|
||||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
||||||
],
|
],
|
||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
'disableFormLogin' => false,
|
'disableFormLogin' => false,
|
||||||
'disableBasicAuth' => false,
|
'disableBasicAuth' => false,
|
||||||
'disableOIDCLogin' => false
|
'disableOIDCLogin' => false
|
||||||
],
|
],
|
||||||
'globalOtpauthUrl' => ""
|
'globalOtpauthUrl' => "",
|
||||||
|
'enableWebDAV' => false,
|
||||||
|
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
class AuthModel {
|
class AuthModel
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the user's role from the users file.
|
* Retrieves the user's role from the users file.
|
||||||
@@ -11,7 +12,8 @@ class AuthModel {
|
|||||||
* @param string $username
|
* @param string $username
|
||||||
* @return string|null The role string (e.g. "1" for admin) or null if not found.
|
* @return string|null The role string (e.g. "1" for admin) or null if not found.
|
||||||
*/
|
*/
|
||||||
public static function getUserRole(string $username): ?string {
|
public static function getUserRole(string $username): ?string
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (file_exists($usersFile)) {
|
if (file_exists($usersFile)) {
|
||||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
@@ -23,7 +25,7 @@ class AuthModel {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates the user using form-based credentials.
|
* Authenticates the user using form-based credentials.
|
||||||
*
|
*
|
||||||
@@ -31,7 +33,8 @@ class AuthModel {
|
|||||||
* @param string $password
|
* @param string $password
|
||||||
* @return array|false Returns an associative array with user data (role, totp_secret) on success or false on failure.
|
* @return array|false Returns an associative array with user data (role, totp_secret) on success or false on failure.
|
||||||
*/
|
*/
|
||||||
public static function authenticate(string $username, string $password) {
|
public static function authenticate(string $username, string $password)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -51,14 +54,15 @@ class AuthModel {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads failed login attempts from a file.
|
* Loads failed login attempts from a file.
|
||||||
*
|
*
|
||||||
* @param string $file
|
* @param string $file
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function loadFailedAttempts(string $file): array {
|
public static function loadFailedAttempts(string $file): array
|
||||||
|
{
|
||||||
if (file_exists($file)) {
|
if (file_exists($file)) {
|
||||||
$data = json_decode(file_get_contents($file), true);
|
$data = json_decode(file_get_contents($file), true);
|
||||||
if (is_array($data)) {
|
if (is_array($data)) {
|
||||||
@@ -67,7 +71,7 @@ class AuthModel {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves failed login attempts into a file.
|
* Saves failed login attempts into a file.
|
||||||
*
|
*
|
||||||
@@ -75,17 +79,19 @@ class AuthModel {
|
|||||||
* @param array $data
|
* @param array $data
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function saveFailedAttempts(string $file, array $data): void {
|
public static function saveFailedAttempts(string $file, array $data): void
|
||||||
|
{
|
||||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
|
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a user's TOTP secret from the users file.
|
* Retrieves a user's TOTP secret from the users file.
|
||||||
*
|
*
|
||||||
* @param string $username
|
* @param string $username
|
||||||
* @return string|null Returns the decrypted TOTP secret or null if not set.
|
* @return string|null Returns the decrypted TOTP secret or null if not set.
|
||||||
*/
|
*/
|
||||||
public static function getUserTOTPSecret(string $username): ?string {
|
public static function getUserTOTPSecret(string $username): ?string
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -98,14 +104,15 @@ class AuthModel {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the folder-only permission for a given user.
|
* Loads the folder-only permission for a given user.
|
||||||
*
|
*
|
||||||
* @param string $username
|
* @param string $username
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function loadFolderPermission(string $username): bool {
|
public static function loadFolderPermission(string $username): bool
|
||||||
|
{
|
||||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||||
if (file_exists($permissionsFile)) {
|
if (file_exists($permissionsFile)) {
|
||||||
$content = file_get_contents($permissionsFile);
|
$content = file_get_contents($permissionsFile);
|
||||||
@@ -121,4 +128,31 @@ class AuthModel {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Validate a remember-me token and return its stored payload.
|
||||||
|
*
|
||||||
|
* @param string $token
|
||||||
|
* @return array|null Returns ['username'=>…, 'expiry'=>…, 'isAdmin'=>…] or null if invalid/expired.
|
||||||
|
*/
|
||||||
|
public static function validateRememberToken(string $token): ?array
|
||||||
|
{
|
||||||
|
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||||
|
if (! file_exists($tokFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and decode the full token store
|
||||||
|
$encrypted = file_get_contents($tokFile);
|
||||||
|
$json = decryptData($encrypted, $GLOBALS['encryptionKey']);
|
||||||
|
$all = json_decode($json, true) ?: [];
|
||||||
|
|
||||||
|
// Lookup and expiry check
|
||||||
|
if (empty($all[$token]) || !isset($all[$token]['expiry']) || $all[$token]['expiry'] < time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid token—return its payload
|
||||||
|
return $all[$token];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
194
start.sh
194
start.sh
@@ -1,162 +1,112 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
echo "🚀 Running start.sh..."
|
echo "🚀 Running start.sh..."
|
||||||
|
|
||||||
# Warn if default persistent tokens key is in use
|
# 1) Token‐key warning
|
||||||
if [ "$PERSISTENT_TOKENS_KEY" = "default_please_change_this_key" ]; then
|
if [ "${PERSISTENT_TOKENS_KEY}" = "default_please_change_this_key" ]; then
|
||||||
echo "⚠️ WARNING: Using default persistent tokens key. Please override PERSISTENT_TOKENS_KEY for production."
|
echo "⚠️ WARNING: Using default persistent tokens key—override for production."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update config.php based on environment variables
|
# 2) Update config.php based on environment variables
|
||||||
CONFIG_FILE="/var/www/config/config.php"
|
CONFIG_FILE="/var/www/config/config.php"
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
if [ -f "${CONFIG_FILE}" ]; then
|
||||||
echo "🔄 Updating config.php based on environment variables..."
|
echo "🔄 Updating config.php from env vars..."
|
||||||
if [ -n "$TIMEZONE" ]; then
|
[ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
|
||||||
echo " Setting TIMEZONE to $TIMEZONE"
|
[ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
|
||||||
sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '$TIMEZONE');|" "$CONFIG_FILE"
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
fi
|
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
|
||||||
if [ -n "$DATE_TIME_FORMAT" ]; then
|
|
||||||
echo "🔄 Setting DATE_TIME_FORMAT to $DATE_TIME_FORMAT"
|
|
||||||
sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '$DATE_TIME_FORMAT');|" "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then
|
|
||||||
echo "🔄 Setting TOTAL_UPLOAD_SIZE to $TOTAL_UPLOAD_SIZE"
|
|
||||||
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '$TOTAL_UPLOAD_SIZE');|" "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
if [ -n "$SECURE" ]; then
|
|
||||||
echo "🔄 Setting SECURE to $SECURE"
|
|
||||||
sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '$SECURE';|" "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
if [ -n "$SHARE_URL" ]; then
|
|
||||||
echo "🔄 Setting SHARE_URL to $SHARE_URL"
|
|
||||||
sed -i "s|define('SHARE_URL',[[:space:]]*'[^']*');|define('SHARE_URL', '$SHARE_URL');|" "$CONFIG_FILE"
|
|
||||||
fi
|
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}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure the PHP configuration directory exists
|
# 2.1) Prepare metadata/log for Apache logs
|
||||||
|
mkdir -p /var/www/metadata/log
|
||||||
|
chown www-data:www-data /var/www/metadata/log
|
||||||
|
chmod 775 /var/www/metadata/log
|
||||||
|
|
||||||
|
mkdir -p /var/www/sessions
|
||||||
|
chown www-data:www-data /var/www/sessions
|
||||||
|
chmod 700 /var/www/sessions
|
||||||
|
|
||||||
|
# 2.2) Prepare other dynamic dirs
|
||||||
|
for d in uploads users metadata; do
|
||||||
|
tgt="/var/www/${d}"
|
||||||
|
mkdir -p "${tgt}"
|
||||||
|
chown www-data:www-data "${tgt}"
|
||||||
|
chmod 775 "${tgt}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3) Ensure PHP config dir & set upload limits
|
||||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||||
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
# Update PHP upload limits at runtime if TOTAL_UPLOAD_SIZE is set.
|
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
|
||||||
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then
|
cat > /etc/php/8.3/apache2/conf.d/99-custom.ini <<EOF
|
||||||
echo "🔄 Updating PHP upload limits with TOTAL_UPLOAD_SIZE=$TOTAL_UPLOAD_SIZE"
|
upload_max_filesize = ${TOTAL_UPLOAD_SIZE}
|
||||||
echo "upload_max_filesize = $TOTAL_UPLOAD_SIZE" > /etc/php/8.3/apache2/conf.d/99-custom.ini
|
post_max_size = ${TOTAL_UPLOAD_SIZE}
|
||||||
echo "post_max_size = $TOTAL_UPLOAD_SIZE" >> /etc/php/8.3/apache2/conf.d/99-custom.ini
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update Apache LimitRequestBody based on TOTAL_UPLOAD_SIZE if set.
|
# 4) Adjust Apache LimitRequestBody
|
||||||
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
size_str=$(echo "$TOTAL_UPLOAD_SIZE" | tr '[:upper:]' '[:lower:]')
|
# convert to bytes
|
||||||
factor=1
|
size_str=$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')
|
||||||
case "${size_str: -1}" in
|
case "${size_str: -1}" in
|
||||||
g)
|
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
|
||||||
factor=$((1024*1024*1024))
|
m) factor=$((1024*1024)); num=${size_str%m} ;;
|
||||||
size_num=${size_str%g}
|
k) factor=1024; num=${size_str%k} ;;
|
||||||
;;
|
*) factor=1; num=${size_str} ;;
|
||||||
m)
|
|
||||||
factor=$((1024*1024))
|
|
||||||
size_num=${size_str%m}
|
|
||||||
;;
|
|
||||||
k)
|
|
||||||
factor=1024
|
|
||||||
size_num=${size_str%k}
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
size_num=$size_str
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
LIMIT_REQUEST_BODY=$((size_num * factor))
|
LIMIT_REQUEST_BODY=$(( num * factor ))
|
||||||
echo "🔄 Setting Apache LimitRequestBody to $LIMIT_REQUEST_BODY bytes (from TOTAL_UPLOAD_SIZE=$TOTAL_UPLOAD_SIZE)"
|
echo "🔄 Setting Apache LimitRequestBody to ${LIMIT_REQUEST_BODY} bytes"
|
||||||
cat <<EOF > /etc/apache2/conf-enabled/limit_request_body.conf
|
cat > /etc/apache2/conf-enabled/limit_request_body.conf <<EOF
|
||||||
<Directory "/var/www/public">
|
<Directory "/var/www/public">
|
||||||
LimitRequestBody $LIMIT_REQUEST_BODY
|
LimitRequestBody ${LIMIT_REQUEST_BODY}
|
||||||
</Directory>
|
</Directory>
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set Apache Timeout (default is 300 seconds)
|
# 5) Configure Apache timeout (600s)
|
||||||
echo "🔄 Setting Apache Timeout to 600 seconds"
|
cat > /etc/apache2/conf-enabled/timeout.conf <<EOF
|
||||||
cat <<EOF > /etc/apache2/conf-enabled/timeout.conf
|
|
||||||
Timeout 600
|
Timeout 600
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "🔥 Final Apache Timeout configuration:"
|
# 6) Override ports if provided
|
||||||
cat /etc/apache2/conf-enabled/timeout.conf
|
if [ -n "${HTTP_PORT:-}" ]; then
|
||||||
|
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
|
||||||
# Update Apache ports if environment variables are provided
|
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf
|
||||||
if [ -n "$HTTP_PORT" ]; then
|
fi
|
||||||
echo "🔄 Setting Apache HTTP port to $HTTP_PORT"
|
if [ -n "${HTTPS_PORT:-}" ]; then
|
||||||
sed -i "s/^Listen 80$/Listen $HTTP_PORT/" /etc/apache2/ports.conf
|
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf
|
||||||
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:$HTTP_PORT>/" /etc/apache2/sites-available/000-default.conf
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$HTTPS_PORT" ]; then
|
# 7) Set ServerName
|
||||||
echo "🔄 Setting Apache HTTPS port to $HTTPS_PORT"
|
if [ -n "${SERVER_NAME:-}" ]; then
|
||||||
sed -i "s/^Listen 443$/Listen $HTTPS_PORT/" /etc/apache2/ports.conf
|
echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
|
||||||
fi
|
|
||||||
|
|
||||||
# Update Apache ServerName if environment variable is provided
|
|
||||||
if [ -n "$SERVER_NAME" ]; then
|
|
||||||
echo "🔄 Setting Apache ServerName to $SERVER_NAME"
|
|
||||||
echo "ServerName $SERVER_NAME" >> /etc/apache2/apache2.conf
|
|
||||||
else
|
else
|
||||||
echo "🔄 Setting Apache ServerName to default: FileRise"
|
|
||||||
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
|
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Final /etc/apache2/ports.conf content:"
|
# 8) Prepare dynamic data directories with least privilege
|
||||||
cat /etc/apache2/ports.conf
|
for d in uploads users metadata; do
|
||||||
|
tgt="/var/www/${d}"
|
||||||
|
mkdir -p "${tgt}"
|
||||||
|
chown www-data:www-data "${tgt}"
|
||||||
|
chmod 775 "${tgt}"
|
||||||
|
done
|
||||||
|
|
||||||
echo "📁 Web app is served from /var/www/public."
|
# 9) Initialize persistent files if absent
|
||||||
|
|
||||||
# Ensure the uploads folder exists in /var/www
|
|
||||||
mkdir -p /var/www/uploads
|
|
||||||
echo "🔑 Fixing permissions for /var/www/uploads..."
|
|
||||||
chown -R ${PUID:-99}:${PGID:-100} /var/www/uploads
|
|
||||||
chmod -R 775 /var/www/uploads
|
|
||||||
|
|
||||||
# Ensure the users folder exists in /var/www
|
|
||||||
mkdir -p /var/www/users
|
|
||||||
echo "🔑 Fixing permissions for /var/www/users..."
|
|
||||||
chown -R ${PUID:-99}:${PGID:-100} /var/www/users
|
|
||||||
chmod -R 775 /var/www/users
|
|
||||||
|
|
||||||
# Ensure the metadata folder exists in /var/www
|
|
||||||
mkdir -p /var/www/metadata
|
|
||||||
echo "🔑 Fixing permissions for /var/www/metadata..."
|
|
||||||
chown -R ${PUID:-99}:${PGID:-100} /var/www/metadata
|
|
||||||
chmod -R 775 /var/www/metadata
|
|
||||||
|
|
||||||
# Create users.txt only if it doesn't already exist (preserving persistent data)
|
|
||||||
if [ ! -f /var/www/users/users.txt ]; then
|
if [ ! -f /var/www/users/users.txt ]; then
|
||||||
echo "ℹ️ users.txt not found in persistent storage; creating new file..."
|
|
||||||
echo "" > /var/www/users/users.txt
|
echo "" > /var/www/users/users.txt
|
||||||
chown ${PUID:-99}:${PGID:-100} /var/www/users/users.txt
|
chown www-data:www-data /var/www/users/users.txt
|
||||||
chmod 664 /var/www/users/users.txt
|
chmod 664 /var/www/users/users.txt
|
||||||
else
|
|
||||||
echo "ℹ️ users.txt already exists; preserving persistent data."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create createdTags.json only if it doesn't already exist (preserving persistent data)
|
|
||||||
if [ ! -f /var/www/metadata/createdTags.json ]; then
|
if [ ! -f /var/www/metadata/createdTags.json ]; then
|
||||||
echo "ℹ️ createdTags.json not found in persistent storage; creating new file..."
|
|
||||||
echo "[]" > /var/www/metadata/createdTags.json
|
echo "[]" > /var/www/metadata/createdTags.json
|
||||||
chown ${PUID:-99}:${PGID:-100} /var/www/metadata/createdTags.json
|
chown www-data:www-data /var/www/metadata/createdTags.json
|
||||||
chmod 664 /var/www/metadata/createdTags.json
|
chmod 664 /var/www/metadata/createdTags.json
|
||||||
else
|
|
||||||
echo "ℹ️ createdTags.json already exists; preserving persistent data."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Optionally, fix permissions for the rest of /var/www
|
|
||||||
echo "🔑 Fixing permissions for /var/www..."
|
|
||||||
find /var/www -type f -exec chmod 664 {} \;
|
|
||||||
find /var/www -type d -exec chmod 775 {} \;
|
|
||||||
chown -R ${PUID:-99}:${PGID:-100} /var/www
|
|
||||||
|
|
||||||
echo "🔥 Final PHP configuration (90-custom.ini):"
|
|
||||||
cat /etc/php/8.3/apache2/conf.d/90-custom.ini
|
|
||||||
|
|
||||||
echo "🔥 Final Apache configuration (limit_request_body.conf):"
|
|
||||||
cat /etc/apache2/conf-enabled/limit_request_body.conf
|
|
||||||
|
|
||||||
echo "🔥 Starting Apache..."
|
echo "🔥 Starting Apache..."
|
||||||
exec apachectl -D FOREGROUND
|
exec apachectl -D FOREGROUND
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<IfModule mod_php7.c>
|
|
||||||
php_flag engine off
|
|
||||||
</IfModule>
|
|
||||||
<IfModule mod_php.c>
|
|
||||||
php_flag engine off
|
|
||||||
</IfModule>
|
|
||||||
Options -Indexes
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<Files "users.txt">
|
|
||||||
Require all denied
|
|
||||||
</Files>
|
|
||||||
Reference in New Issue
Block a user