Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9d332d60 | ||
|
|
1983f7705f | ||
|
|
6b2bf0ba70 | ||
|
|
6d9715169c | ||
|
|
0645a3712a | ||
|
|
ebc32ea965 | ||
|
|
078db33458 | ||
|
|
04f5cbe31f | ||
|
|
b5a7d8d559 | ||
|
|
58f8485b02 | ||
|
|
3e1da9c335 | ||
|
|
6bf6206e1c | ||
|
|
f9c60951c9 | ||
|
|
06b3f28df0 | ||
|
|
89f124250c | ||
|
|
66f13fd6a7 | ||
|
|
a81d9cb940 | ||
|
|
13b8871200 | ||
|
|
2792c05c1c | ||
|
|
6ccfc88acb | ||
|
|
7f1d59b33a | ||
|
|
e4e8b108d2 |
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
|
||||||
|
|||||||
144
CHANGELOG.md
144
CHANGELOG.md
@@ -1,5 +1,139 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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`
|
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
|
||||||
@@ -21,6 +155,16 @@
|
|||||||
- `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize`
|
- `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize`
|
||||||
- Updated `AdminModel` & `AdminController` to persist and validate new settings
|
- Updated `AdminModel` & `AdminController` to persist and validate new settings
|
||||||
- Enhanced `shareFolder()` view to pull from admin config and format the max‑upload‑size label
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
134
Dockerfile
134
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,94 +16,119 @@ 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 (these can be overridden via the Unraid template)
|
|
||||||
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 \
|
|
||||||
TERM=xterm \
|
|
||||||
UPLOAD_MAX_FILESIZE=5G \
|
|
||||||
POST_MAX_SIZE=5G \
|
|
||||||
TOTAL_UPLOAD_SIZE=5G \
|
|
||||||
PERSISTENT_TOKENS_KEY=default_please_change_this_key \
|
PERSISTENT_TOKENS_KEY=default_please_change_this_key \
|
||||||
PUID=99 \
|
PUID=99 PGID=100
|
||||||
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/*
|
|
||||||
|
|
||||||
# Remap www-data to the PUID/PGID provided
|
# Remap www-data to the PUID/PGID provided for safe bind mounts
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
# only change the UID if it’s not already correct
|
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u "${PUID}" www-data; fi; \
|
||||||
if [ "$(id -u www-data)" != "${PUID}" ]; then \
|
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g "${PGID}" www-data 2>/dev/null || true; fi; \
|
||||||
usermod -u "${PUID}" www-data; \
|
|
||||||
fi; \
|
|
||||||
# attempt to change the GID, but ignore “already exists” errors
|
|
||||||
if [ "$(id -g www-data)" != "${PGID}" ]; then \
|
|
||||||
groupmod -g "${PGID}" www-data 2>/dev/null || true; \
|
|
||||||
fi; \
|
|
||||||
# finally set www-data’s primary group to PGID (will succeed if the group exists)
|
|
||||||
usermod -g "${PGID}" www-data
|
usermod -g "${PGID}" www-data
|
||||||
|
|
||||||
# Copy application tuning and code
|
# 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
|
||||||
|
|
||||||
# Ensure the webroot is owned by the remapped www-data user
|
# 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>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
# Expose ports and set up the startup 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
|
||||||
|
|||||||
14
README.md
14
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:**
|
||||||
|
|
||||||
@@ -115,9 +115,9 @@ If you prefer to run FileRise on a traditional web server (LAMP stack or similar
|
|||||||
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/public`). 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:** 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.)
|
||||||
|
|
||||||
- **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)
|
||||||
@@ -245,6 +245,12 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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>
|
|
||||||
35
public/api.php
Normal file
35
public/api.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 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="api.php?spec=1"></redoc>
|
||||||
|
<div id="redoc-container"></div>
|
||||||
|
<script>
|
||||||
|
if (!customElements.get('redoc')) {
|
||||||
|
Redoc.init('api.php?spec=1', {}, document.getElementById('redoc-container'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</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.3"; // Update this version string as needed
|
const version = "v1.2.6"; // 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";
|
||||||
|
|||||||
@@ -91,7 +91,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 +101,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 +117,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 +131,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 +162,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 +179,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 +201,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>
|
||||||
@@ -345,4 +339,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,48 @@ export function renderFileTable(folder, container) {
|
|||||||
|
|
||||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||||
|
|
||||||
|
// 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 +518,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 +574,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 +589,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 +627,59 @@ 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
|
// 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 +697,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 +712,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 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,19 +402,19 @@ class FolderController
|
|||||||
* @return void Outputs HTML content.
|
* @return void Outputs HTML content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function formatBytes($bytes)
|
function formatBytes($bytes)
|
||||||
{
|
{
|
||||||
if ($bytes < 1024) {
|
if ($bytes < 1024) {
|
||||||
return $bytes . " B";
|
return $bytes . " B";
|
||||||
} elseif ($bytes < 1024 * 1024) {
|
} elseif ($bytes < 1024 * 1024) {
|
||||||
return round($bytes / 1024, 2) . " KB";
|
return round($bytes / 1024, 2) . " KB";
|
||||||
} elseif ($bytes < 1024 * 1024 * 1024) {
|
} elseif ($bytes < 1024 * 1024 * 1024) {
|
||||||
return round($bytes / (1024 * 1024), 2) . " MB";
|
return round($bytes / (1024 * 1024), 2) . " MB";
|
||||||
} else {
|
} else {
|
||||||
return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
|
return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function shareFolder(): void
|
public function shareFolder(): void
|
||||||
{
|
{
|
||||||
// Retrieve GET parameters.
|
// Retrieve GET parameters.
|
||||||
@@ -759,72 +759,83 @@ class FolderController
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// (Optional) JavaScript for toggling view modes (list/gallery).
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var viewMode = 'list';
|
// JavaScript for toggling view modes (list/gallery) and wiring up gallery clicks
|
||||||
window.imageCache = window.imageCache || {};
|
var viewMode = 'list';
|
||||||
var filesData = <?php echo json_encode($files); ?>;
|
var token = '<?php echo addslashes($token); ?>';
|
||||||
|
var filesData = <?php echo json_encode($files); ?>;
|
||||||
|
|
||||||
// Use the shared‑folder relative path (from your model), not realFolderPath
|
// Build the download URL base
|
||||||
// $data['folder'] should be something like "eafwef/testfolder2/test/new folder two"
|
var downloadBase = window.location.origin +
|
||||||
var rawRelPath = "<?php echo addslashes($data['folder']); ?>";
|
'/api/folder/downloadSharedFile.php?token=' +
|
||||||
// Split into segments, encode each segment, then re-join
|
encodeURIComponent(token) +
|
||||||
var folderSegments = rawRelPath
|
'&file=';
|
||||||
.split('/')
|
|
||||||
.map(encodeURIComponent)
|
|
||||||
.join('/');
|
|
||||||
|
|
||||||
function renderGalleryView() {
|
function toggleViewMode() {
|
||||||
var galleryContainer = document.getElementById("galleryViewContainer");
|
var listEl = document.getElementById('listViewContainer');
|
||||||
var html = '<div class="shared-gallery-container">';
|
var galleryEl = document.getElementById('galleryViewContainer');
|
||||||
filesData.forEach(function(file) {
|
var btn = document.getElementById('toggleBtn');
|
||||||
// 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();
|
if (viewMode === 'list') {
|
||||||
var thumbnail;
|
viewMode = 'gallery';
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].indexOf(ext) >= 0) {
|
listEl.style.display = 'none';
|
||||||
thumbnail = '<img src="' + fileUrl + '" alt="' + file + '">';
|
renderGalleryView();
|
||||||
|
galleryEl.style.display = 'block';
|
||||||
|
btn.textContent = 'Switch to List View';
|
||||||
} else {
|
} else {
|
||||||
thumbnail = '<span class="material-icons">insert_drive_file</span>';
|
viewMode = 'list';
|
||||||
|
galleryEl.style.display = 'none';
|
||||||
|
listEl.style.display = 'block';
|
||||||
|
btn.textContent = 'Switch to Gallery View';
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Wire up the toggle button
|
||||||
|
document.getElementById('toggleBtn')
|
||||||
|
.addEventListener('click', toggleViewMode);
|
||||||
|
|
||||||
|
function renderGalleryView() {
|
||||||
|
var galleryContainer = document.getElementById('galleryViewContainer');
|
||||||
|
var html = '<div class="shared-gallery-container">';
|
||||||
|
|
||||||
|
filesData.forEach(function(file) {
|
||||||
|
var encodedName = encodeURIComponent(file);
|
||||||
|
var fileUrl = downloadBase + encodedName;
|
||||||
|
var ext = file.split('.').pop().toLowerCase();
|
||||||
|
var thumb;
|
||||||
|
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].indexOf(ext) >= 0) {
|
||||||
|
thumb = '<img src="' + fileUrl + '" alt="' + file + '">';
|
||||||
|
} else {
|
||||||
|
thumb = '<span class="material-icons">insert_drive_file</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html +=
|
||||||
|
'<div class="shared-gallery-card">' +
|
||||||
|
'<div class="gallery-preview" data-url="' + fileUrl + '" style="cursor:pointer;">' +
|
||||||
|
thumb +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="gallery-info">' +
|
||||||
|
'<span class="gallery-file-name">' + file + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
galleryContainer.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire up each thumbnail click
|
||||||
|
galleryContainer.querySelectorAll('.gallery-preview')
|
||||||
|
.forEach(function(el) {
|
||||||
|
el.addEventListener('click', function() {
|
||||||
|
window.location.href = el.dataset.url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for manual invocation if needed
|
||||||
|
window.renderGalleryView = renderGalleryView;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (99-custom.ini):"
|
|
||||||
cat /etc/php/8.3/apache2/conf.d/99-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