Compare commits

...

33 Commits

Author SHA1 Message Date
Ryan
76f5ed5c96 shared-folder: externalize gallery view JS & enforce CSP compliance 2025-04-27 18:18:00 -04:00
Ryan
18f588dc24 Update Manual Installation 2025-04-27 17:23:43 -04:00
Ryan
491c686762 fix: advancedSearchToggle in renderFileTable & renderGalleryView 2025-04-27 17:08:49 -04:00
Ryan
25303df677 fixed: Pagination controls & Items-per-page dropdown 2025-04-27 16:50:22 -04:00
Ryan
ae0d63b86f fix: checkbox in toolbar does not select all files (Fixes #25) 2025-04-27 15:34:41 -04:00
Ryan
41ade2e205 refactor/fix: api redoc 2025-04-26 17:31:51 -04:00
Ryan
0a9d332d60 refactor(auth): relocate logout handler to main.js 2025-04-26 04:33:01 -04:00
Ryan
1983f7705f enhance CSP for iframe and refactor gallery view event handlers 2025-04-26 04:08:56 -04:00
Ryan
6b2bf0ba70 Refactor event binding in domUtils & fileListView 2025-04-26 03:33:23 -04:00
Ryan
6d9715169c Harden security: enable CSP, add SRI, and externalize inline scripts 2025-04-26 02:28:02 -04:00
Ryan
0645a3712a Use Material icons for dark/light toggle and simplify download flows 2025-04-25 20:40:00 -04:00
Ryan
ebc32ea965 consolidate & protect API docs with php wrapper 2025-04-24 19:34:09 -04:00
Ryan
078db33458 Embed API documentation as a full-screen modal 2025-04-24 17:35:41 -04:00
Ryan
04f5cbe31f chore: update install docs, secure API docs, refine Docker vhost, remove unused folders 2025-04-24 17:02:50 -04:00
Ryan
b5a7d8d559 continue breadcrumb update 2025-04-23 23:17:23 -04:00
Ryan
58f8485b02 fix(breadcrumb): prevent XSS in title breadcrumbs – closes #24 2025-04-23 22:45:25 -04:00
Ryan
3e1da9c335 Add missing permissions in UserModel.php for TOTP login. 2025-04-23 21:15:55 -04:00
Ryan
6bf6206e1c Add missing permissions for TOTP login 2025-04-23 21:14:59 -04:00
Ryan
f9c60951c9 Removed Old CSRF logic 2025-04-23 19:53:47 -04:00
Ryan
06b3f28df0 New fetchWithCsrf with fallback for session change. start.sh session directory added. 2025-04-23 09:53:21 -04:00
Ryan
89f124250c Fixed totp isAdmin when session is missing but remember_me_token cookie present 2025-04-23 02:30:43 -04:00
Ryan
66f13fd6a7 dockerignore cleanup 2025-04-23 01:50:24 -04:00
Ryan
a81d9cb940 Enhance remember me 2025-04-23 01:47:27 -04:00
Ryan
13b8871200 docker: remove symlink add alias for uploads folder 2025-04-22 22:28:06 -04:00
Ryan
2792c05c1c docker: consolidate config & security improvements 2025-04-22 21:34:21 -04:00
Ryan
6ccfc88acb Composer & WebDAV readme changes 2025-04-22 19:27:53 -04:00
Ryan
7f1d59b33a add acknowledgements to README and LICENSE 2025-04-22 19:06:33 -04:00
Ryan
e4e8b108d2 Add permissions to workflow 2025-04-22 18:11:42 -04:00
Ryan
242661a9c9 New Admin Panel settings (enableWebDAV & shareMaxUploadSize) 2025-04-22 17:11:19 -04:00
Ryan
ca3e2f316c PUID/PGID changes 2025-04-22 08:19:10 -04:00
Ryan
6ff4aa5f34 support PUID/PGID env vars & update Unraid template 2025-04-22 08:06:29 -04:00
Ryan
1eb54b8e6e Updated WebDav and curl readme 2025-04-21 13:23:54 -04:00
Ryan
4a6c424540 Add sabre/dav to dependencies and fix resumable.js url 2025-04-21 11:57:01 -04:00
38 changed files with 1763 additions and 857 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
# dockerignore
.git
.gitignore
.github
.github/**
Dockerfile*
resources/
node_modules/
*.log
tmp/
.env
.vscode/
.DS_Store

2
.gitattributes vendored
View File

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

View File

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

View File

@@ -1,5 +1,197 @@
# Changelog # Changelog
## Changes 4/27/2025 1.2.7
- **Select-All** checkbox now correctly toggles all `.file-checkbox` inputs
- Updated `toggleAllCheckboxes(masterCheckbox)` to call `updateRowHighlight()` on each row so selections get the `.row-selected` highlight
- **Master checkbox sync** in toolbar
- Enhanced `updateFileActionButtons()` to set the header checkbox to checked, unchecked, or indeterminate based on how many files are selected
- Fixed Pagination controls & Items-per-page dropdown
- Fixed `#advancedSearchToggle` in both `renderFileTable()` and `renderGalleryView()`
- **Shared folder gallery view logic**
- Introduced new `public/js/sharedFolderView.js` containing all DOMContentLoaded wiring, `toggleViewMode()`, gallery rendering, and event listeners
- Embedded a non-executing JSON payload in `shareFolder.php`
- **`FolderController::shareFolder()` / `shareFolder.php`**
- Removed all inline `onclick="…"` attributes and inline `<script>` blocks
- Added `<script type="application/json" id="shared-data">…</script>` to export `$token` and `$files`
- Added `<script src="/js/sharedFolderView.js" defer></script>` to load the external view logic
- **Styling updates**
- Added `.toggle-btn` CSS for blue header-style toggle button and applied it in JS
- Added `.pagination a:hover { background-color: #0056b3; }` to match button hover
- Tweaked `body` padding and `header h1` margins to reduce whitespace above header
---
## Changes 4/26/2025 1.2.6
**Apache / Dockerfile (CSP)**
- Enabled Apaches `mod_headers` in the Dockerfile (`a2enmod headers ssl deflate expires proxy proxy_fcgi rewrite`)
- Added a strong `Content-Security-Policy` header in the vhost configs to lock down allowed sources for scripts, styles, fonts, images, and connections
**index.html & CDN Includes**
- Applied Subresource Integrity (`integrity` + `crossorigin="anonymous"`) to all static CDN assets (Bootstrap CSS, CodeMirror CSS/JS, Resumable.js, DOMPurify, Fuse.js)
- Omitted SRI on Google Fonts & Material Icons links (dynamic per-browser CSS)
- Removed all inline `<script>` and `onclick` attributes; now all behaviors live in external JS modules
**auth.js (Logout Handling)**
- Moved the logout-on-`?logout=1` snippet from inline HTML into `auth.js`
- In `DOMContentLoaded`, attached a `click` listener to `#logoutBtn` that POSTs to `/api/auth/logout.php` and reloads
**fileActions.js (Modal Button Handlers)**
- Externalized the cancel/download buttons for single-file and ZIP-download modals by adding `click` listeners in `fileActions.js`
- Removed the inline `onclick` attributes from `#cancelDownloadFile` and `#confirmSingleDownloadButton` in the HTML
- Ensured all file-action modals (delete, download, extract, copy, move, rename) now use JS event handlers instead of inline code
**domUtils.js**
- **Removed** all inline `onclick` and `onchange` attributes from:
- `buildSearchAndPaginationControls` (advanced search toggle, prev/next buttons, items-per-page selector)
- `buildFileTableHeader` (select-all checkbox)
- `buildFileTableRow` (download, edit, preview, rename buttons)
- **Retained** all original logic (file-type icon detection, shift-select, debounce, custom confirm modal, etc.)
**fileListView.js**
- **Stopped** generating inline `onclick` handlers in both table and gallery views.
- **Added** `data-` attributes on actionable elements:
- `data-download-name`, `data-download-folder`
- `data-edit-name`, `data-edit-folder`
- `data-rename-name`, `data-rename-folder`
- `data-preview-url`, `data-preview-name`
- IDs on controls: `#advancedSearchToggle`, `#searchInput`, `#prevPageBtn`, `#nextPageBtn`, `#selectAll`, `#itemsPerPageSelect`
- **Introduced** `attachListControlListeners()` to bind all events via `addEventListener` immediately after rendering, preserving every interaction without inline code.
**Additional changes**
- **Security**: Added `frame-src 'self'` to the Content-Security-Policy header so that the embedded API docs iframe can load from our own origin without relaxing JS restrictions.
- **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders.
- **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant.
- Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready.
- Added Content-Security-Policy for `<Files "api.php">` block to allow embedding the ReDoc iframe.
- Extracted inline ReDoc init into `public/js/redoc-init.js` and updated `public/api.php` to use deferred `<script>` tags.
---
## Changes 4/25/2025
- Switch singlefile 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 Dockerfiles 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 hasnt expired
- Returns stored payload (`username`, `expiry`, `isAdmin`, etc.) or `null` if invalid
**authController (checkAuth)**
- **Enhanced** “remember-me” re-login path at top of `checkAuth()`
- Calls `AuthModel::validateRememberToken()` when session is missing but `remember_me_token` cookie present
- Repopulates `$_SESSION['authenticated']`, `username`, `isAdmin`, `folderOnly`, `readOnly`, `disableUpload` from payload
- Regenerates session ID and CSRF token, then immediately returns JSON and exits
- **Updated** `userController.php`
- Fixed totp isAdmin when session is missing but `remember_me_token` cookie present
- **loadCsrfToken()**
- Now reads `X-CSRF-Token` response header first, falls back to JSON `csrf_token` if header absent
- Updates `window.csrfToken`, `window.SHARE_URL`, and `<meta>` tags with the new values
- **fetchWithCsrf(url, options)**
- Sends `credentials: 'include'` and current `X-CSRF-Token` on every request
- Handles “soft-failure” JSON (`{ csrf_expired: true, csrf_token }`): updates token and retries once without a 403 in DevTools
- On HTTP 403 fallback: reads new token from header or `/api/auth/token.php`, updates token, and retries once
- **start.sh**
- Session directory setup
- Always sends `credentials: 'include'` and `X-CSRF-Token: window.csrfToken` s
- On HTTP 403, automatically fetches a fresh CSRF token (from the response header or `/api/auth/token.php`) and retries the request once
- Always returns the real `Response` object (no more “clone.json” on every 200)
- Now calls `fetchWithCsrf('/api/auth/token.php')` to guarantee a fresh token
- Checks `res.ok`, then parses JSON to extract `csrf_token` and `share_url`
- Updates both `window.csrfToken` and the `<meta name="csrf-token">` & `<meta name="share-url">` tags
- Removed Old CSRF logic that cloned every successful response and parsed its JSON body
- Removed Any “soft-failure” JSON peek on non-403 responses
- Add missing permissions in `UserModel.php` for TOTP login.
- **Prevent XSS in breadcrumbs**
- Replaced `innerHTML` calls in `fileListTitle` with a new `updateBreadcrumbTitle()` helper that uses `textContent` + `DocumentFragment`.
- Introduced `renderBreadcrumbFragment()` to build each breadcrumb segment as a `<span class="breadcrumb-link" data-folder="…">` node.
- Added `setupBreadcrumbDelegation()` to handle clicks via event delegation on the container, eliminating per-element listeners.
- Removed any raw HTML concatenation to satisfy CodeQL and ensure all breadcrumb text is safely escaped.
## Changes 4/22/2025 v1.2.3
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
- New `PUID` and `PGID` config options in the Unraid Community Apps template
- Dockerfile:
- startup (`start.sh`) now runs as root to write `/etc/php` & `/etc/apache2` configs
- `wwwdata` user is remapped at buildtime to the supplied `PUID:PGID`, then Apache drops privileges to that user
- Unraid template: removed recommendation to use `--user`; replaced with `PUID`, `PGID`, and `Container Port` variables
- “Permission denied” errors when forcing `--user 99:100` on Unraid by ensuring startup runs as root
- Dockerfile silence group issue
- `enableWebDAV` toggle in Admin Panel (default: disabled)
- **Admin Panel enhancements**
- New `enableWebDAV` boolean setting
- New `sharedMaxUploadSize` numeric setting (bytes)
- **Shared Folder upload size**
- `sharedMaxUploadSize` is now enforced in `FolderModel::uploadToSharedFolder`
- Upload form header on sharedfolder page dynamically shows “(X MB max size)”
- **API updates**
- `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize`
- Updated `AdminModel` & `AdminController` to persist and validate new settings
- Enhanced `shareFolder()` view to pull from admin config and format the maxuploadsize label
- Restored the MIT license copyright line that was inadvertently removed.
- Move .htaccess to public folder this was mistake since API refactor.
- gitattributes to ignore resources/ & .github/ on export
- Hardened `Dockerfile` permissions: all code files owned by `root:www-data` (dirs `755`, files `644`), only `uploads/`, `users/` and `metadata/` are writable by `www-data` (`775`)
- `.dockerignore` entry to exclude the `.github` directory from build context
- `start.sh`:
- Creates and secures `metadata/log` for Apache logs
- Dynamically creates and sets permissions on `uploads`, `users`, and `metadata` directories at startup
- Apache VirtualHost updated to redirect `ErrorLog` and `CustomLog` into `/var/www/metadata/log`
- docker: remove symlink add alias for uploads folder
---
## Changes 4/21/2025 v1.2.2 ## Changes 4/21/2025 v1.2.2
### Added ### Added
@@ -40,7 +232,7 @@
Refactored to: Refactored to:
1. Fetch CSRF 1. Fetch CSRF
2. POST credentials to `/api/auth/auth.php` 2. POST credentials to `/api/auth/auth.php`
3. On `totp_required`, refetch CSRF *again* before calling `openTOTPLoginModal()` 3. On `totp_required`, refetch CSRF again before calling `openTOTPLoginModal()`
4. Handle full logins vs. TOTP flows cleanly. 4. Handle full logins vs. TOTP flows cleanly.
- **TOTP handlers update** - **TOTP handlers update**
@@ -986,7 +1178,7 @@ The enhancements extend the existing drag-and-drop functionality by adding a hea
- Adjusted file preview and icon styling for better alignment. - Adjusted file preview and icon styling for better alignment.
- Centered the header and optimized the layout for a clean, modern appearance. - Centered the header and optimized the layout for a clean, modern appearance.
*This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.* This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.
--- ---

View File

@@ -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 Apaches default index
RUN mkdir -p /var/www && rm -f /var/www/html/index.html RUN mkdir -p /var/www && rm -f /var/www/html/index.html
# **Copy the FileRise source** (where your composer.json lives)
COPY . /var/www COPY . /var/www
############################# #############################
@@ -19,88 +16,123 @@ COPY . /var/www
############################# #############################
FROM composer:2 AS composer FROM composer:2 AS composer
WORKDIR /app WORKDIR /app
# **Copy composer files from the source** and install
COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./ COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./
RUN composer install --no-dev --optimize-autoloader RUN composer install --no-dev --optimize-autoloader # production-ready autoloader
############################# #############################
# Final Stage runtime image # Final Stage runtime image
############################# #############################
FROM ubuntu:24.04 FROM ubuntu:24.04
LABEL by=error311 LABEL by=error311
# Set basic environment variables
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \ HOME=/root \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 TERM=xterm \
LANG=en_US.UTF-8 \ UPLOAD_MAX_FILESIZE=5G POST_MAX_SIZE=5G TOTAL_UPLOAD_SIZE=5G \
LANGUAGE=en_US.UTF-8 \ PERSISTENT_TOKENS_KEY=default_please_change_this_key \
TERM=xterm \ PUID=99 PGID=100
UPLOAD_MAX_FILESIZE=5G \
POST_MAX_SIZE=5G \
TOTAL_UPLOAD_SIZE=5G \
PERSISTENT_TOKENS_KEY=default_please_change_this_key
ARG PUID=99
ARG PGID=100
# Install Apache, PHP, and required extensions # Install Apache, PHP, and required extensions
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y && \ apt-get upgrade -y && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
apache2 \ apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
php \ ca-certificates curl git openssl && \
php-json \ apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
php-curl \
php-zip \
php-mbstring \
php-gd \
php-xml \
ca-certificates \
curl \
git \
openssl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Fix www-data UID/GID # Remap www-data to the PUID/PGID provided for safe bind mounts
RUN set -eux; \ RUN set -eux; \
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u ${PUID} www-data || true; fi; \ if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u "${PUID}" www-data; fi; \
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g ${PGID} www-data || true; fi; \ if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g "${PGID}" www-data 2>/dev/null || true; fi; \
usermod -g ${PGID} www-data usermod -g "${PGID}" www-data
# Copy application code and vendor directory # Copy config, code, and vendor
COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
COPY --from=appsource /var/www /var/www COPY --from=appsource /var/www /var/www
COPY --from=composer /app/vendor /var/www/vendor COPY --from=composer /app/vendor /var/www/vendor
# Fix ownership & permissions # Secure permissions: code read-only, only data dirs writable
RUN chown -R www-data:www-data /var/www && chmod -R 775 /var/www RUN chown -R root:www-data /var/www && \
find /var/www -type d -exec chmod 755 {} \; && \
find /var/www -type f -exec chmod 644 {} \; && \
mkdir -p /var/www/public/uploads /var/www/users /var/www/metadata && \
chown -R www-data:www-data /var/www/public/uploads /var/www/users /var/www/metadata && \
chmod -R 775 /var/www/public/uploads /var/www/users /var/www/metadata # writable upload areas
# Create a symlink for uploads folder in public directory. # Apache site configuration
RUN cd /var/www/public && ln -s ../uploads uploads
# Configure Apache
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80> <VirtualHost *:80>
# Global settings
TraceEnable off
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5
Timeout 60
ServerAdmin webmaster@localhost ServerAdmin webmaster@localhost
DocumentRoot /var/www/public DocumentRoot /var/www/public
# Security headers for all responses
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
</IfModule>
# Compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
</IfModule>
# Cache static assets
<IfModule mod_expires.c>
ExpiresActive on
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType text/css "access plus 1 week"
ExpiresByType application/javascript "access plus 3 hour"
</IfModule>
# Protect uploads directory
Alias /uploads/ /var/www/uploads/
<Directory "/var/www/uploads/">
Options -Indexes
AllowOverride None
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Require all granted
</Directory>
# Public directory
<Directory "/var/www/public"> <Directory "/var/www/public">
AllowOverride All AllowOverride All
Require all granted Require all granted
DirectoryIndex index.php index.html DirectoryIndex index.html index.php
</Directory> </Directory>
ErrorLog /var/log/apache2/error.log
CustomLog /var/log/apache2/access.log combined # Deny access to hidden files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<Files "api.php">
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
</Files>
ErrorLog /var/www/metadata/log/error.log
CustomLog /var/www/metadata/log/access.log combined
</VirtualHost> </VirtualHost>
EOF EOF
# Enable the rewrite and headers modules # Enable required modules
RUN a2enmod rewrite headers RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
# Expose ports and set up start script
EXPOSE 80 443 EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh

View File

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

View File

@@ -1,7 +1,7 @@
# FileRise # FileRise
**Elevate your File Management** A modern, self-hosted web file manager. **Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze. Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
**4/3/2025 Video demo:** **4/3/2025 Video demo:**
@@ -20,7 +20,7 @@ Upload, organize, and share files through a sleek web interface. **FileRise** is
- 🗃️ **Folder Sharing & File Sharing:** Easily share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls, and file sizes are displayed in MB for clarity. Share files with others using one-time or expiring public links (with password protection if desired) convenient for sending individual files without exposing the whole app. - 🗃️ **Folder Sharing & File Sharing:** Easily share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls, and file sizes are displayed in MB for clarity. Share files with others using one-time or expiring public links (with password protection if desired) convenient for sending individual files without exposing the whole app.
- 🔌 **WebDAV Support:** Mount FileRise as a network drive or connect via any WebDAV client. Supports standard file operations (upload/download/rename/delete) and direct `curl`/CLI access for scripting and automation. FolderOnly users are restricted to their personal folder, while admins and unrestricted users have full access. Compatible with Cyberduck, WinSCP, native OS drive mounts, and more. - 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it headless from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl%C2%A0(WebDAV)) quickstart for examples. FolderOnly users are restricted to their personal directory, while admins and unrestricted users have full access.
- 📚 **API Documentation:** Fully autogenerated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc. - 📚 **API Documentation:** Fully autogenerated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
@@ -108,16 +108,16 @@ FileRise will be accessible at `http://localhost:8080` (or your servers IP).
If you prefer to run FileRise on a traditional web server (LAMP stack or similar): If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
- **Requirements:** PHP 8.1 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed. - **Requirements:** PHP 8.3 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases). - **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
``` bash ``` bash
git clone https://github.com/error311/FileRise.git git clone https://github.com/error311/FileRise.git
``` ```
Place the files into your web servers 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 servers directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.) If you skip this, FileRise will still work, but OIDC login wont be available. - **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist): - **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist):
@@ -149,7 +149,7 @@ Now navigate to the FileRise URL in your browser. On first load, youll be pro
## Quickstart: Mount via WebDAV ## Quickstart: Mount via WebDAV
Once FileRise is running, you can mount it like any other network drive: Once FileRise is running, you must enable WebDAV in admin panel to access it.
```bash ```bash
# Linux (GVFS/GIO) # Linux (GVFS/GIO)
@@ -232,19 +232,25 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7) - **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0) - **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0) - **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
- **[sabre/dav"](https://github.com/sabre-io/dav)** (^4.4) - **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
### Client-Side Libraries ### Client-Side Libraries
- **Google Fonts** [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons)) - **Google Fonts** [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2) - **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) For code editing functionality. - **[CodeMirror](https://codemirror.net/)** (v5.65.5) For code editing functionality.
- **[Resumable.js](http://www.resumablejs.com/)** (v1.1.0) For file uploads. - **[Resumable.js](https://github.com/23/resumable.js/)** (v1.1.0) For file uploads.
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) For sanitizing HTML. - **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) For sanitizing HTML.
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) For indexed, fuzzy searching. - **[Fuse.js](https://fusejs.io/)** (v6.6.2) For indexed, fuzzy searching.
--- ---
## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
---
## License ## License
This project is open-source under the MIT License. That means youre 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 youre free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!

View File

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

View File

@@ -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 didnt render, fall back to init()
if (!customElements.get('redoc')) {
Redoc.init('openapi.json', {}, document.getElementById('redoc-container'));
}
</script>
</body>
</html>

31
public/api.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
// public/api.php
require_once __DIR__ . '/../config/config.php';
if (empty($_SESSION['authenticated'])) {
header('Location: /index.html?redirect=/api.php');
exit;
}
if (isset($_GET['spec'])) {
header('Content-Type: application/json');
readfile(__DIR__ . '/../openapi.json.dist');
exit;
}
?><!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title>
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
crossorigin="anonymous"></script>
<script defer src="/js/redoc-init.js"></script>
</head>
<body>
<redoc spec-url="api.php?spec=1"></redoc>
<div id="redoc-container"></div>
</body>
</html>

View File

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

View File

@@ -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>
@@ -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>
@@ -394,9 +395,18 @@
<!-- 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">
Preparing your download...
</h4>
<!-- spinner -->
<span class="material-icons download-spinner">autorenew</span> <span class="material-icons download-spinner">autorenew</span>
<p data-i18n-key="preparing_download">Preparing your download...</p>
<!-- these were missing -->
<progress id="downloadProgressBar" value="0" max="100" style="width:100%; height:1.5em; display:none;"></progress>
<p>
<span id="downloadProgressPercent" style="display:none;">0%</span>
</p>
</div> </div>
</div> </div>
@@ -405,14 +415,11 @@
<div class="modal-content" style="text-align: center; padding: 20px;"> <div class="modal-content" style="text-align: center; padding: 20px;">
<h4 data-i18n-key="download_file">Download File</h4> <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> <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" /> <input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename"
placeholder="Filename" />
<div style="margin-top: 15px; text-align: right;"> <div style="margin-top: 15px; text-align: right;">
<button id="cancelDownloadFile" class="btn btn-secondary" <button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
onclick="document.getElementById('downloadFileModal').style.display = 'none';" <button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
data-i18n-key="cancel">Cancel</button>
<button id="confirmSingleDownloadButton" class="btn btn-primary"
onclick="confirmSingleDownload()"
data-i18n-key="download">Download</button>
</div> </div>
</div> </div>
</div> </div>
@@ -420,7 +427,8 @@
<!-- 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;">&times;</span> <span id="closeChangePasswordModal"
style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</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;" />

View File

@@ -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 {
@@ -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())

View File

@@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js'; import { loadAdminConfigFunc } from './auth.js';
const version = "v1.2.2"; // Update this version string as needed const version = "v1.2.7"; // Update this version string as needed
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null; let lastLoginData = null;
@@ -230,14 +230,39 @@ export function openUserPanel() {
<!-- New API Docs link --> <!-- New API Docs link -->
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<a href="api.html" target="_blank" class="btn btn-secondary"> <button type="button" id="openApiModalBtn" class="btn btn-secondary">
${t("api_docs") || "API Docs"} ${t("api_docs") || "API Docs"}
</a> </button>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(userPanelModal); document.body.appendChild(userPanelModal);
const apiModal = document.createElement("div");
apiModal.id = "apiModal";
apiModal.style.cssText = `
position: fixed; top:0; left:0; width:100vw; height:100vh;
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
align-items: center; justify-content: center;
`;
// api.php
apiModal.innerHTML = `
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
<div class="editor-close-btn" id="closeApiModal">&times;</div>
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
</div>
`;
document.body.appendChild(apiModal);
document.getElementById("openApiModalBtn").addEventListener("click", () => {
apiModal.style.display = "flex";
});
document.getElementById("closeApiModal").addEventListener("click", () => {
apiModal.style.display = "none";
});
// Handlers… // Handlers…
document.getElementById("closeUserPanel").addEventListener("click", () => { document.getElementById("closeUserPanel").addEventListener("click", () => {
userPanelModal.style.display = "none"; userPanelModal.style.display = "none";
@@ -246,6 +271,7 @@ export function openUserPanel() {
document.getElementById("changePasswordModal").style.display = "block"; document.getElementById("changePasswordModal").style.display = "block";
}); });
// TOTP checkbox // TOTP checkbox
const totpCheckbox = document.getElementById("userTOTPEnabled"); const totpCheckbox = document.getElementById("userTOTPEnabled");
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true"; totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
@@ -597,6 +623,7 @@ export function openAdminPanel() {
} }
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc); if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl; if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const isDarkMode = document.body.classList.contains("dark-mode"); const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = ` const modalContentStyles = `
@@ -611,6 +638,7 @@ export function openAdminPanel() {
max-height: 90vh; max-height: 90vh;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"}; border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
`; `;
let adminModal = document.getElementById("adminPanelModal"); let adminModal = document.getElementById("adminPanelModal");
if (!adminModal) { if (!adminModal) {
@@ -663,6 +691,28 @@ export function openAdminPanel() {
<label for="disableOIDCLogin">${t("disable_oidc_login")}</label> <label for="disableOIDCLogin">${t("disable_oidc_login")}</label>
</div> </div>
</fieldset> </fieldset>
<!-- New WebDAV setting -->
<fieldset style="margin-bottom: 15px;">
<legend>WebDAV Access</legend>
<div class="form-group">
<input type="checkbox" id="enableWebDAV" />
<label for="enableWebDAV">Enable WebDAV</label>
</div>
</fieldset>
<!-- End WebDAV setting -->
<!-- New Shared Max Upload Size setting -->
<fieldset style="margin-bottom: 15px;">
<legend>Shared Max Upload Size (bytes)</legend>
<div class="form-group">
<input type="number" id="sharedMaxUploadSize" class="form-control"
placeholder="e.g. 52428800" />
<small>Enter maximum bytes allowed for shared-folder uploads</small>
</div>
</fieldset>
<!-- End Shared Max Upload Size setting -->
<fieldset style="margin-bottom: 15px;"> <fieldset style="margin-bottom: 15px;">
<legend>${t("oidc_configuration")}</legend> <legend>${t("oidc_configuration")}</legend>
<div class="form-group"> <div class="form-group">
@@ -698,33 +748,34 @@ export function openAdminPanel() {
`; `;
document.body.appendChild(adminModal); document.body.appendChild(adminModal);
// Bind closing events that will use our enhanced close function. // Bind closing
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
adminModal.addEventListener("click", (e) => { adminModal.addEventListener("click", e => { if (e.target === adminModal) closeAdminPanel(); });
if (e.target === adminModal) closeAdminPanel();
});
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
// Bind other buttons. // Bind other buttons
document.getElementById("adminOpenAddUser").addEventListener("click", () => { document.getElementById("adminOpenAddUser").addEventListener("click", () => {
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus(); document.getElementById("newUsername").focus();
}); });
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => { document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
if (typeof window.loadUserList === "function") { if (typeof window.loadUserList === "function") window.loadUserList();
window.loadUserList();
}
toggleVisibility("removeUserModal", true); toggleVisibility("removeUserModal", true);
}); });
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => { document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
openUserPermissionsModal(); openUserPermissionsModal();
}); });
document.getElementById("saveAdminSettings").addEventListener("click", () => {
// Save handler
document.getElementById("saveAdminSettings").addEventListener("click", () => {
const disableFormLoginCheckbox = document.getElementById("disableFormLogin"); const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth"); const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin"); const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length; const enableWebDAVCheckbox = document.getElementById("enableWebDAV");
const sharedMaxUploadSizeInput = document.getElementById("sharedMaxUploadSize");
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox]
.filter(cb => cb.checked).length;
if (totalDisabled === 3) { if (totalDisabled === 3) {
showToast(t("at_least_one_login_method")); showToast(t("at_least_one_login_method"));
disableOIDCLoginCheckbox.checked = false; disableOIDCLoginCheckbox.checked = false;
@@ -738,8 +789,8 @@ export function openAdminPanel() {
} }
return; return;
} }
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
const newOIDCConfig = { const newOIDCConfig = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(), providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(), clientId: document.getElementById("oidcClientId").value.trim(),
@@ -749,13 +800,18 @@ export function openAdminPanel() {
const disableFormLogin = disableFormLoginCheckbox.checked; const disableFormLogin = disableFormLoginCheckbox.checked;
const disableBasicAuth = disableBasicAuthCheckbox.checked; const disableBasicAuth = disableBasicAuthCheckbox.checked;
const disableOIDCLogin = disableOIDCLoginCheckbox.checked; const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
const enableWebDAV = enableWebDAVCheckbox.checked;
const sharedMaxUploadSize = parseInt(sharedMaxUploadSizeInput.value, 10) || 0;
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim(); const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
sendRequest("/api/admin/updateConfig.php", "POST", { sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: newHeaderTitle, header_title: newHeaderTitle,
oidc: newOIDCConfig, oidc: newOIDCConfig,
disableFormLogin, disableFormLogin,
disableBasicAuth, disableBasicAuth,
disableOIDCLogin, disableOIDCLogin,
enableWebDAV,
sharedMaxUploadSize,
globalOtpauthUrl globalOtpauthUrl
}, { "X-CSRF-Token": window.csrfToken }) }, { "X-CSRF-Token": window.csrfToken })
.then(response => { .then(response => {
@@ -764,26 +820,32 @@ export function openAdminPanel() {
localStorage.setItem("disableFormLogin", disableFormLogin); localStorage.setItem("disableFormLogin", disableFormLogin);
localStorage.setItem("disableBasicAuth", disableBasicAuth); localStorage.setItem("disableBasicAuth", disableBasicAuth);
localStorage.setItem("disableOIDCLogin", disableOIDCLogin); localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
localStorage.setItem("enableWebDAV", enableWebDAV);
localStorage.setItem("sharedMaxUploadSize", sharedMaxUploadSize);
if (typeof window.updateLoginOptionsUI === "function") { if (typeof window.updateLoginOptionsUI === "function") {
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }); window.updateLoginOptionsUI({
disableFormLogin,
disableBasicAuth,
disableOIDCLogin
});
} }
// Update the captured initial state since the changes have now been saved.
captureInitialAdminConfig(); captureInitialAdminConfig();
closeAdminPanel(); closeAdminPanel();
loadAdminConfigFunc(); loadAdminConfigFunc();
} else { } else {
showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error"))); showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error")));
} }
}) })
.catch(() => { }); .catch(() => { });
}); });
// Enforce login option constraints. // Enforce login option constraints.
const disableFormLoginCheckbox = document.getElementById("disableFormLogin"); const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth"); const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin"); const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
function enforceLoginOptionConstraint(changedCheckbox) { function enforceLoginOptionConstraint(changedCheckbox) {
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length; const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox]
.filter(cb => cb.checked).length;
if (changedCheckbox.checked && totalDisabled === 3) { if (changedCheckbox.checked && totalDisabled === 3) {
showToast(t("at_least_one_login_method")); showToast(t("at_least_one_login_method"));
changedCheckbox.checked = false; changedCheckbox.checked = false;
@@ -793,13 +855,17 @@ export function openAdminPanel() {
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
// Initial checkbox and input states
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// Capture initial state after the modal loads.
captureInitialAdminConfig(); captureInitialAdminConfig();
} else { } else {
// Update existing modal and show
adminModal.style.backgroundColor = overlayBackground; adminModal.style.backgroundColor = overlayBackground;
const modalContent = adminModal.querySelector(".modal-content"); const modalContent = adminModal.querySelector(".modal-content");
if (modalContent) { if (modalContent) {
@@ -815,6 +881,8 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
adminModal.style.display = "flex"; adminModal.style.display = "flex";
captureInitialAdminConfig(); captureInitialAdminConfig();
} }
@@ -837,6 +905,8 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true"; document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true"; document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true"; document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
document.getElementById("enableWebDAV").checked = localStorage.getItem("enableWebDAV") === "true";
document.getElementById("sharedMaxUploadSize").value = localStorage.getItem("sharedMaxUploadSize") || "";
adminModal.style.display = "flex"; adminModal.style.display = "flex";
captureInitialAdminConfig(); captureInitialAdminConfig();
} else { } else {

View File

@@ -25,8 +25,9 @@ export function toggleAllCheckboxes(masterCheckbox) {
const checkboxes = document.querySelectorAll(".file-checkbox"); const checkboxes = document.querySelectorAll(".file-checkbox");
checkboxes.forEach(chk => { checkboxes.forEach(chk => {
chk.checked = masterCheckbox.checked; chk.checked = masterCheckbox.checked;
updateRowHighlight(chk);
}); });
updateFileActionButtons(); // update buttons based on current selection updateFileActionButtons();
} }
export function updateFileActionButtons() { export function updateFileActionButtons() {
@@ -38,6 +39,21 @@ export function updateFileActionButtons() {
const zipBtn = document.getElementById("downloadZipBtn"); const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn"); const extractZipBtn = document.getElementById("extractZipBtn");
// keep the “select all” in sync ——
const master = document.getElementById("selectAll");
if (master) {
if (selectedCheckboxes.length === fileCheckboxes.length) {
master.checked = true;
master.indeterminate = false;
} else if (selectedCheckboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
if (fileCheckboxes.length === 0) { if (fileCheckboxes.length === 0) {
if (copyBtn) copyBtn.style.display = "none"; if (copyBtn) copyBtn.style.display = "none";
if (moveBtn) moveBtn.style.display = "none"; if (moveBtn) moveBtn.style.display = "none";
@@ -101,7 +117,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
<div class="input-group"> <div class="input-group">
<!-- Advanced Search Toggle Button --> <!-- Advanced Search Toggle Button -->
<div class="input-group-prepend"> <div class="input-group-prepend">
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" onclick="toggleAdvancedSearch()" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}"> <button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i> <i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
</button> </button>
</div> </div>
@@ -117,9 +133,9 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
</div> </div>
<div class="col-12 col-md-4 text-left"> <div class="col-12 col-md-4 text-left">
<div class="d-flex justify-content-center justify-content-md-start align-items-center"> <div class="d-flex justify-content-center justify-content-md-start align-items-center">
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${t("prev")}</button> <button id="prevPageBtn" class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""}>${t("prev")}</button>
<span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span> <span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span>
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">${t("next")}</button> <button id="nextPageBtn" class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""}>${t("next")}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -131,7 +147,7 @@ export function buildFileTableHeader(sortOrder) {
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th> <th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
@@ -162,15 +178,15 @@ export function buildFileTableRow(file, folderPath) {
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`; previewIcon = `<i class="material-icons">audiotrack</i>`;
} }
previewButton = `<button class="btn btn-sm btn-info preview-btn" onclick="event.stopPropagation(); previewFile('${folderPath + encodeURIComponent(file.name)}', '${safeFileName}')"> previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
${previewIcon} ${previewIcon}
</button>`; </button>`;
} }
return ` return `
<tr onclick="toggleRowSelection(event, '${safeFileName}')" class="clickable-row"> <tr class="clickable-row">
<td> <td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);"> <input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td> </td>
<td class="file-name-cell">${safeFileName}</td> <td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td> <td class="hide-small nowrap">${safeModified}</td>
@@ -179,22 +195,16 @@ export function buildFileTableRow(file, folderPath) {
<td class="hide-small hide-medium nowrap">${safeUploader}</td> <td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td> <td>
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;"> <div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
<button type="button" class="btn btn-sm btn-success download-btn" <button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')"
title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" <button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('edit')}">
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</button> </button>
` : ""} ` : ""}
${previewButton} ${previewButton}
<button class="btn btn-sm btn-warning rename-btn" <button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
</div> </div>
@@ -207,7 +217,7 @@ 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("")}
@@ -277,8 +287,6 @@ export function toggleRowSelection(event, fileName) {
const start = Math.min(currentIndex, lastIndex); const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex);
// If neither CTRL nor Meta is pressed, you might choose
// to clear existing selections. For this example we leave existing selections intact.
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
const cb = allRows[i].querySelector(".file-checkbox"); const cb = allRows[i].querySelector(".file-checkbox");
if (cb) { if (cb) {
@@ -346,3 +354,6 @@ export function showCustomConfirmModal(message) {
noBtn.addEventListener("click", onNo); noBtn.addEventListener("click", onNo);
}); });
} }
window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;

View File

@@ -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";
// Build the URL for download.php using GET parameters. // 3) Build the direct download URL
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, { // 4) Trigger native browser download
method: "GET",
credentials: "include"
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error("Failed to download file: " + text);
});
}
return response.blob();
})
.then(blob => {
if (!blob || blob.size === 0) {
throw new Error("Received empty file.");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.style.display = "none"; a.href = downloadURL;
a.href = url;
a.download = fileName; a.download = fileName;
a.style.display = "none";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); document.body.removeChild(a);
a.remove();
// Hide the progress modal. // 5) Notify the user
document.getElementById("downloadProgressModal").style.display = "none"; showToast("Download started. Check your browsers download manager.");
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) {
@@ -169,14 +145,19 @@ export function handleExtractZipSelected(e) {
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. if (titleEl) titleEl.textContent = "Extracting files…";
document.getElementById("downloadProgressModal").style.display = "block"; 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",
@@ -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 nameinput 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");
if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
try {
// d) POST and await the ZIP blob
const res = await fetch("/api/file/downloadZip.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken "X-CSRF-Token": window.csrfToken
}, },
body: JSON.stringify({ folder: folder, files: window.filesToDownload }) body: JSON.stringify({
folder: window.currentFolder || "root",
files: window.filesToDownload
}) })
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error("Failed to create zip file: " + text);
}); });
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `Status ${res.status}`);
} }
return response.blob();
}) const blob = await res.blob();
.then(blob => {
if (!blob || blob.size === 0) { if (!blob || blob.size === 0) {
throw new Error("Received empty zip file."); throw new Error("Received empty ZIP file.");
} }
const url = window.URL.createObjectURL(blob);
// e) Hand off to the browsers download manager
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.style.display = "none";
a.href = url; a.href = url;
a.download = zipName; a.download = zipName;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); URL.revokeObjectURL(url);
a.remove(); a.remove();
// Hide the progress modal after download starts
document.getElementById("downloadProgressModal").style.display = "none"; } catch (err) {
showToast("Download started."); console.error("Error downloading ZIP:", err);
}) showToast("Error: " + err.message);
.catch(error => { } finally {
// Hide the progress modal on error // f) Always hide spinner modal
document.getElementById("downloadProgressModal").style.display = "none"; progressModal.style.display = "none";
console.error("Error downloading zip:", error); }
showToast("Error downloading selected files as zip: " + error.message);
});
}); });
} }
}); });
@@ -573,4 +555,22 @@ export function initFileActions() {
} }
} }
// Hook up the singlefile 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;

View File

@@ -340,6 +340,88 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// pagination clicks
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderFileTable(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
// totalPages is computed above in this scope
if (window.currentPage < totalPages) {
window.currentPage++;
renderFileTable(folder, container);
}
});
// ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// items-per-page selector
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderFileTable(folder, container);
});
// hook up the master checkbox
const selectAll = document.getElementById("selectAll");
if (selectAll) {
selectAll.addEventListener("change", () => {
toggleAllCheckboxes(selectAll);
});
}
// 1) Row-click selects the row
fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => {
// grab the underlying checkbox value
const cb = row.querySelector(".file-checkbox");
if (!cb) return;
toggleRowSelection(e, cb.value);
});
});
// 2) Download buttons
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
});
});
// 3) Edit buttons
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder);
});
});
// 4) Rename buttons
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
});
});
// 5) Preview buttons (if you still have a .preview-btn)
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
});
});
createViewToggleButton(); createViewToggleButton();
// Setup event listeners. // Setup event listeners.
@@ -476,21 +558,24 @@ 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
src="${window.imageCache[cacheKey]}"
class="gallery-thumbnail" class="gallery-thumbnail"
data-cache-key="${cacheKey}"
alt="${escapeHTML(file.name)}" alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; 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"
data-cache-key="${cacheKey}"
alt="${escapeHTML(file.name)}" alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} }
@@ -529,9 +614,9 @@ export function renderGalleryView(folder, container) {
<label for="cb-${idSafe}" <label for="cb-${idSafe}"
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label> style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview" <div class="gallery-preview" style="cursor:pointer;"
style="cursor:pointer;" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')"> data-preview-name="${file.name}">
${thumbnail} ${thumbnail}
</div> </div>
@@ -544,22 +629,25 @@ export function renderGalleryView(folder, container) {
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;"> <div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
<button type="button" class="btn btn-sm btn-success download-btn" <button type="button" class="btn btn-sm btn-success download-btn"
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')" data-download-name="${escapeHTML(file.name)}"
data-download-folder="${file.folder || "root"}"
title="${t('download')}"> title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" <button type="button" class="btn btn-sm edit-btn"
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' data-edit-name="${escapeHTML(file.name)}"
title="${t('Edit')}"> data-edit-folder="${file.folder || "root"}"
title="${t('edit')}">
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</button>` : ""} </button>` : ""}
<button class="btn btn-sm btn-warning rename-btn" <button type="button" class="btn btn-sm btn-warning rename-btn"
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' data-rename-name="${escapeHTML(file.name)}"
data-rename-folder="${file.folder || "root"}"
title="${t('rename')}"> title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
<button class="btn btn-sm btn-secondary share-btn" <button type="button" class="btn btn-sm btn-secondary share-btn"
data-file="${escapeHTML(file.name)}" data-file="${escapeHTML(file.name)}"
title="${t('share')}"> title="${t('share')}">
<i class="material-icons">share</i> <i class="material-icons">share</i>
@@ -579,13 +667,93 @@ export function renderGalleryView(folder, container) {
// render // render
fileListContent.innerHTML = galleryHTML; fileListContent.innerHTML = galleryHTML;
// ensure toggle button // --- Now wire up all behaviors without inline handlers ---
createViewToggleButton();
// attach listeners // ADD: pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderGalleryView(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
if (window.currentPage < totalPages) {
window.currentPage++;
renderGalleryView(folder, container);
}
});
// ←— ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// ←— ADD: wire up context-menu in gallery
bindFileListContextMenu();
// ADD: items-per-page selector for gallery
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderGalleryView(folder, container);
});
// cache images on load
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
const key = img.dataset.cacheKey;
img.addEventListener('load', () => cacheImage(img, key));
});
// preview clicks
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
el.addEventListener("click", () => {
previewFile(el.dataset.previewUrl, el.dataset.previewName);
});
});
// download clicks
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
});
});
// edit clicks
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder);
});
});
// rename clicks
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
});
});
// share clicks
fileListContent.querySelectorAll(".share-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const fileName = btn.dataset.file;
const fileObj = fileData.find(f => f.name === fileName);
if (fileObj) {
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
}
});
});
// checkboxes // checkboxes
document.querySelectorAll(".file-checkbox").forEach(cb => { fileListContent.querySelectorAll(".file-checkbox").forEach(cb => {
cb.addEventListener("change", () => updateFileActionButtons()); cb.addEventListener("change", () => updateFileActionButtons());
}); });
@@ -603,14 +771,13 @@ export function renderGalleryView(folder, container) {
}); });
} }
// pagination // pagination functions
window.changePage = newPage => { window.changePage = newPage => {
window.currentPage = newPage; window.currentPage = newPage;
if (window.viewMode === "gallery") renderGalleryView(folder); if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder); else renderFileTable(folder);
}; };
// items per page
window.changeItemsPerPage = cnt => { window.changeItemsPerPage = cnt => {
window.itemsPerPage = +cnt; window.itemsPerPage = +cnt;
localStorage.setItem("itemsPerPage", cnt); localStorage.setItem("itemsPerPage", cnt);
@@ -619,8 +786,9 @@ export function renderGalleryView(folder, container) {
else renderFileTable(folder); else renderFileTable(folder);
}; };
// update toolbar buttons // update toolbar and toggle button
updateFileActionButtons(); updateFileActionButtons();
createViewToggleButton();
} }
// Responsive slider constraints based on screen size. // Responsive slider constraints based on screen size.

View File

@@ -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,6 +337,38 @@ 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.
@@ -418,9 +454,8 @@ export async function loadFolderTree(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();
@@ -434,6 +469,7 @@ export async function loadFolderTree(selectedFolder) {
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) {
@@ -472,6 +509,7 @@ 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();
@@ -500,6 +538,7 @@ export async function loadFolderTree(selectedFolder) {
} }
} }
// 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(async res => {
.then(response => response.json()) if (!res.ok) {
.then(data => { // pull out a JSON error, or fallback to status text
if (data.success) { let err;
showToast("Folder created successfully!"); try {
window.currentFolder = fullFolderName; const j = await res.json();
localStorage.setItem("lastOpenedFolder", fullFolderName); err = j.error || j.message || res.statusText;
loadFolderList(fullFolderName); } catch {
} else { err = res.statusText;
showToast("Error: " + (data.error || "Could not create folder"));
} }
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";
}); });
}); });

View File

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

View File

@@ -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,36 +14,58 @@ 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);
}
return response.json();
}) })
.then(data => { .then(res => {
window.csrfToken = data.csrf_token; if (!res.ok) {
window.SHARE_URL = data.share_url; throw new Error(`Token fetch failed with status ${res.status}`);
let metaCSRF = document.querySelector('meta[name="csrf-token"]');
if (!metaCSRF) {
metaCSRF = document.createElement('meta');
metaCSRF.name = 'csrf-token';
document.head.appendChild(metaCSRF);
} }
metaCSRF.setAttribute('content', data.csrf_token); return res.json();
})
let metaShare = document.querySelector('meta[name="share-url"]'); .then(({ csrf_token, share_url }) => {
if (!metaShare) { // Update global and <meta>
metaShare = document.createElement('meta'); window.csrfToken = csrf_token;
metaShare.name = 'share-url'; let meta = document.querySelector('meta[name="csrf-token"]');
document.head.appendChild(metaShare); if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
} }
metaShare.setAttribute('content', data.share_url); meta.content = csrf_token;
return data; let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
shareMeta.content = share_url;
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(() => {});
}); });
} }
@@ -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"); // 5) OSlevel change: only if no stored pref at load
darkModeToggle.textContent = t("light_mode"); if (!hasStored && window.matchMedia) {
} window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", e => {
document.body.classList.toggle("dark-mode", e.matches);
updateIcon();
}); });
} }
if (localStorage.getItem("darkMode") === null && window.matchMedia) {
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => {
if (event.matches) {
document.body.classList.add("dark-mode");
if (darkModeToggle) darkModeToggle.textContent = t("light_mode");
} else {
document.body.classList.remove("dark-mode");
if (darkModeToggle) darkModeToggle.textContent = t("dark_mode");
}
});
} }
// --- End Dark Mode Persistence --- // --- End Dark Mode Persistence ---

6
public/js/redoc-init.js Normal file
View File

@@ -0,0 +1,6 @@
// public/js/redoc-init.js
if (!customElements.get('redoc')) {
Redoc.init(window.location.origin + '/api.php?spec=1',
{},
document.getElementById('redoc-container'));
}

View File

@@ -0,0 +1,60 @@
// sharedFolderView.js
document.addEventListener('DOMContentLoaded', function() {
let viewMode = 'list';
const payload = JSON.parse(
document.getElementById('shared-data').textContent
);
const token = payload.token;
const filesData = payload.files;
const downloadBase = `${window.location.origin}/api/folder/downloadSharedFile.php?token=${encodeURIComponent(token)}&file=`;
function toggleViewMode() {
const listEl = document.getElementById('listViewContainer');
const galleryEl = document.getElementById('galleryViewContainer');
const btn = document.getElementById('toggleBtn');
if (btn) {
btn.classList.add('toggle-btn');
}
if (viewMode === 'list') {
viewMode = 'gallery';
listEl.style.display = 'none';
renderGalleryView();
galleryEl.style.display = 'block';
btn.textContent = 'Switch to List View';
} else {
viewMode = 'list';
galleryEl.style.display = 'none';
listEl.style.display = 'block';
btn.textContent = 'Switch to Gallery View';
}
}
document.getElementById('toggleBtn').addEventListener('click', toggleViewMode);
function renderGalleryView() {
const galleryContainer = document.getElementById('galleryViewContainer');
let html = '<div class="shared-gallery-container">';
filesData.forEach(file => {
const url = downloadBase + encodeURIComponent(file);
const ext = file.split('.').pop().toLowerCase();
const thumb = /^(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/.test(ext)
? `<img src="${url}" alt="${file}">`
: `<span class="material-icons">insert_drive_file</span>`;
html += `
<div class="shared-gallery-card">
<div class="gallery-preview" data-url="${url}" style="cursor:pointer;">${thumb}</div>
<div class="gallery-info"><span class="gallery-file-name">${file}</span></div>
</div>`;
});
html += '</div>';
galleryContainer.innerHTML = html;
galleryContainer.querySelectorAll('.gallery-preview')
.forEach(el => el.addEventListener('click', () => {
window.location.href = el.dataset.url;
}));
}
window.renderGalleryView = renderGalleryView;
});

View File

@@ -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) Softfail 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);
}); });

View File

@@ -1,6 +1,7 @@
<?php <?php
// public/webdav.php // public/webdav.php
// ─── 0) Forward Basic auth into PHP_AUTH_* for every HTTP verb ─────────────
if ( if (
empty($_SERVER['PHP_AUTH_USER']) empty($_SERVER['PHP_AUTH_USER'])
&& !empty($_SERVER['HTTP_AUTHORIZATION']) && !empty($_SERVER['HTTP_AUTHORIZATION'])
@@ -11,46 +12,58 @@ if (
$_SERVER['PHP_AUTH_PW'] = $p; $_SERVER['PHP_AUTH_PW'] = $p;
} }
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission() require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
// ─── 3) Load your WebDAV directory implementation ────────────────────────── // ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
$adminConfig = AdminModel::getConfig();
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
if (!$enableWebDAV) {
header('HTTP/1.1 403 Forbidden');
echo 'WebDAV access is currently disabled by administrator.';
exit;
}
// ─── 2) Load WebDAV directory implementation ──────────────────────────
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php'; require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
use Sabre\DAV\Server; use Sabre\DAV\Server;
use Sabre\DAV\Auth\Backend\BasicCallBack; use Sabre\DAV\Auth\Backend\BasicCallBack;
use Sabre\DAV\Auth\Plugin as AuthPlugin; use Sabre\DAV\Auth\Plugin as AuthPlugin;
use Sabre\DAV\Browser\Plugin as BrowserPlugin;
use Sabre\DAV\Locks\Plugin as LocksPlugin; use Sabre\DAV\Locks\Plugin as LocksPlugin;
use Sabre\DAV\Locks\Backend\File as LocksFileBackend; use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
use FileRise\WebDAV\FileRiseDirectory; use FileRise\WebDAV\FileRiseDirectory;
// ─── 3) HTTPBasic backend ─────────────────────────────────────────────────
$authBackend = new BasicCallBack(function(string $user, string $pass) { $authBackend = new BasicCallBack(function(string $user, string $pass) {
return \AuthModel::authenticate($user, $pass) !== false; return \AuthModel::authenticate($user, $pass) !== false;
}); });
$authPlugin = new AuthPlugin($authBackend, 'FileRise'); $authPlugin = new AuthPlugin($authBackend, 'FileRise');
// ─── 4) Determine user scope ────────────────────────────────────────────────
$user = $_SERVER['PHP_AUTH_USER'] ?? ''; $user = $_SERVER['PHP_AUTH_USER'] ?? '';
$isAdmin = (\AuthModel::getUserRole($user) === '1'); $isAdmin = (\AuthModel::getUserRole($user) === '1');
$folderOnly = (bool)\AuthModel::loadFolderPermission($user); $folderOnly = (bool)\AuthModel::loadFolderPermission($user);
if ($isAdmin || !$folderOnly) { if ($isAdmin || !$folderOnly) {
// admins or unrestricted users see the full /uploads // Admins (or users without folder-only restriction) see the full /uploads
$rootPath = rtrim(UPLOAD_DIR, '/\\'); $rootPath = rtrim(UPLOAD_DIR, '/\\');
} else { } else {
// folderonly users see only /uploads/{username} // Folderonly users see only /uploads/{username}
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user; $rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
if (!is_dir($rootPath)) { if (!is_dir($rootPath)) {
mkdir($rootPath, 0755, true); mkdir($rootPath, 0755, true);
} }
} }
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
$server = new Server([ $server = new Server([
new FileRiseDirectory($rootPath, $user, $folderOnly), new FileRiseDirectory($rootPath, $user, $folderOnly),
]); ]);
$server->addPlugin($authPlugin); $server->addPlugin($authPlugin);
//$server->addPlugin(new BrowserPlugin()); // optional HTML browser UI
$server->addPlugin( $server->addPlugin(
new LocksPlugin( new LocksPlugin(
new LocksFileBackend(sys_get_temp_dir() . '/sabre-locksdb') new LocksFileBackend(sys_get_temp_dir() . '/sabre-locksdb')

View File

@@ -35,7 +35,9 @@ class AdminController
* @OA\Property(property="disableBasicAuth", type="boolean", example=false), * @OA\Property(property="disableBasicAuth", type="boolean", example=false),
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false) * @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
* ), * ),
* @OA\Property(property="globalOtpauthUrl", type="string", example="") * @OA\Property(property="globalOtpauthUrl", type="string", example=""),
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
* @OA\Property(property="sharedMaxUploadSize", type="integer", example=52428800)
* ) * )
* ), * ),
* @OA\Response( * @OA\Response(
@@ -88,7 +90,9 @@ class AdminController
* @OA\Property(property="disableBasicAuth", type="boolean", example=false), * @OA\Property(property="disableBasicAuth", type="boolean", example=false),
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false) * @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
* ), * ),
* @OA\Property(property="globalOtpauthUrl", type="string", example="") * @OA\Property(property="globalOtpauthUrl", type="string", example=""),
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
* @OA\Property(property="sharedMaxUploadSize", type="integer", example=52428800)
* ) * )
* ), * ),
* @OA\Response( * @OA\Response(
@@ -149,7 +153,7 @@ class AdminController
exit; exit;
} }
// Prepare configuration array. // Prepare existing settings
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : ""; $headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
$oidc = isset($data['oidc']) ? $data['oidc'] : []; $oidc = isset($data['oidc']) ? $data['oidc'] : [];
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : ''; $oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
@@ -183,6 +187,22 @@ class AdminController
} }
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : ""; $globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
// ── NEW: enableWebDAV flag ──────────────────────────────────────
$enableWebDAV = false;
if (array_key_exists('enableWebDAV', $data)) {
$enableWebDAV = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
} elseif (isset($data['features']['enableWebDAV'])) {
$enableWebDAV = filter_var($data['features']['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// ── NEW: sharedMaxUploadSize ──────────────────────────────────────
$sharedMaxUploadSize = null;
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sharedMaxUploadSize = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
} elseif (isset($data['features']['sharedMaxUploadSize'])) {
$sharedMaxUploadSize = filter_var($data['features']['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
}
$configUpdate = [ $configUpdate = [
'header_title' => $headerTitle, 'header_title' => $headerTitle,
'oidc' => [ 'oidc' => [
@@ -196,7 +216,9 @@ class AdminController
'disableBasicAuth' => $disableBasicAuth, 'disableBasicAuth' => $disableBasicAuth,
'disableOIDCLogin' => $disableOIDCLogin, 'disableOIDCLogin' => $disableOIDCLogin,
], ],
'globalOtpauthUrl' => $globalOtpauthUrl 'globalOtpauthUrl' => $globalOtpauthUrl,
'enableWebDAV' => $enableWebDAV,
'sharedMaxUploadSize' => $sharedMaxUploadSize // ← NEW
]; ];
// Delegate to the model. // Delegate to the model.

View File

@@ -341,22 +341,27 @@ 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; $usersFile = USERS_DIR . USERS_FILE;
// setup mode?
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
error_log("checkAuth: setup mode");
echo json_encode(['setup' => true]);
exit();
}
if (empty($_SESSION['authenticated'])) {
echo json_encode(['authenticated' => false]);
exit();
}
// TOTP enabled?
$totp = false; $totp = false;
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) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) { if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
@@ -364,17 +369,58 @@ class AuthController
break; break;
} }
} }
}
$isAdmin = ((int)AuthModel::getUserRole($_SESSION['username']) === 1); 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;
// 2) Setup mode?
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
error_log("checkAuth: setup mode");
echo json_encode(['setup' => true]);
exit();
}
// 3) Session-based auth
if (empty($_SESSION['authenticated'])) {
echo json_encode(['authenticated' => false]);
exit();
}
// 4) TOTP enabled?
$totp = false;
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;
}
}
// 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;
} }

View File

@@ -401,6 +401,20 @@ class FolderController
* *
* @return void Outputs HTML content. * @return void Outputs HTML content.
*/ */
function formatBytes($bytes)
{
if ($bytes < 1024) {
return $bytes . " B";
} elseif ($bytes < 1024 * 1024) {
return round($bytes / 1024, 2) . " KB";
} elseif ($bytes < 1024 * 1024 * 1024) {
return round($bytes / (1024 * 1024), 2) . " MB";
} else {
return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
}
}
public function shareFolder(): void public function shareFolder(): void
{ {
// Retrieve GET parameters. // Retrieve GET parameters.
@@ -495,12 +509,14 @@ class FolderController
exit; exit;
} }
// Extract data for the HTML view. // Load admin config so we can pull the sharedMaxUploadSize
$folderName = $data['folder']; require_once PROJECT_ROOT . '/src/models/AdminModel.php';
$files = $data['files']; $adminConfig = AdminModel::getConfig();
$currentPage = $data['currentPage']; $sharedMaxUploadSize = isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize'])
$totalPages = $data['totalPages']; ? (int)$adminConfig['sharedMaxUploadSize']
: null;
// For humanreadable formatting
function formatBytes($bytes) function formatBytes($bytes)
{ {
if ($bytes < 1024) { if ($bytes < 1024) {
@@ -514,6 +530,12 @@ class FolderController
} }
} }
// Extract data for the HTML view.
$folderName = $data['folder'];
$files = $data['files'];
$currentPage = $data['currentPage'];
$totalPages = $data['totalPages'];
// Build the HTML view. // Build the HTML view.
header("Content-Type: text/html; charset=utf-8"); header("Content-Type: text/html; charset=utf-8");
?> ?>
@@ -528,13 +550,18 @@ class FolderController
body { body {
background: #f2f2f2; background: #f2f2f2;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
padding: 20px; padding: 0px 20px 20px 20px;
color: #333; color: #333;
} }
.header { .header {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
margin-top: 0;
}
.header h1 {
margin-top: 0;
} }
.container { .container {
@@ -639,6 +666,28 @@ class FolderController
font-size: 0.9rem; font-size: 0.9rem;
color: #777; color: #777;
} }
.toggle-btn {
background-color: #007BFF;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 1rem;
cursor: pointer;
}
.toggle-btn:hover {
background-color: #0056b3;
}
.pagination a:hover {
background-color: #0056b3;
}
.pagination span {
cursor: default;
}
</style> </style>
</head> </head>
@@ -648,7 +697,7 @@ class FolderController
</div> </div>
<div class="container"> <div class="container">
<!-- Toggle Button --> <!-- Toggle Button -->
<button id="toggleBtn" class="toggle-btn" onclick="toggleViewMode()">Switch to Gallery View</button> <button id="toggleBtn" class="toggle-btn">Switch to Gallery View</button>
<!-- List View Container --> <!-- List View Container -->
<div id="listViewContainer"> <div id="listViewContainer">
@@ -717,7 +766,11 @@ class FolderController
<!-- Upload Container (if uploads are allowed by the share record) --> <!-- Upload Container (if uploads are allowed by the share record) -->
<?php if (isset($data['record']['allowUpload']) && $data['record']['allowUpload'] == 1): ?> <?php if (isset($data['record']['allowUpload']) && $data['record']['allowUpload'] == 1): ?>
<div class="upload-container"> <div class="upload-container">
<h3>Upload File (50mb max size)</h3> <h3>Upload File
<?php if ($sharedMaxUploadSize !== null): ?>
(<?php echo formatBytes($sharedMaxUploadSize); ?> max size)
<?php endif; ?>
</h3>
<form action="/api/folder/uploadToSharedFolder.php" method="post" enctype="multipart/form-data"> <form action="/api/folder/uploadToSharedFolder.php" method="post" enctype="multipart/form-data">
<!-- Pass the share token so the upload endpoint can verify --> <!-- Pass the share token so the upload endpoint can verify -->
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>"> <input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
@@ -731,75 +784,14 @@ class FolderController
<div class="footer"> <div class="footer">
&copy; <?php echo date("Y"); ?> FileRise. All rights reserved. &copy; <?php echo date("Y"); ?> FileRise. All rights reserved.
</div> </div>
<!-- non-executing JSON payload, never blocked by CSP -->
<script> <script type="application/json" id="shared-data">
// (Optional) JavaScript for toggling view modes (list/gallery). {
var viewMode = 'list'; "token": <?php echo json_encode($token, JSON_HEX_TAG); ?>,
window.imageCache = window.imageCache || {}; "files": <?php echo json_encode($files, JSON_HEX_TAG); ?>
var filesData = <?php echo json_encode($files); ?>;
// Use the sharedfolder relative path (from your model), not realFolderPath
// $data['folder'] should be something like "eafwef/testfolder2/test/new folder two"
var rawRelPath = "<?php echo addslashes($data['folder']); ?>";
// Split into segments, encode each segment, then re-join
var folderSegments = rawRelPath
.split('/')
.map(encodeURIComponent)
.join('/');
function renderGalleryView() {
var galleryContainer = document.getElementById("galleryViewContainer");
var html = '<div class="shared-gallery-container">';
filesData.forEach(function(file) {
// Encode the filename too
var fileName = encodeURIComponent(file);
var fileUrl = window.location.origin +
'/uploads/' +
folderSegments +
'/' +
fileName +
'?t=' +
Date.now();
var ext = file.split('.').pop().toLowerCase();
var thumbnail;
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].indexOf(ext) >= 0) {
thumbnail = '<img src="' + fileUrl + '" alt="' + file + '">';
} else {
thumbnail = '<span class="material-icons">insert_drive_file</span>';
}
html +=
'<div class="shared-gallery-card">' +
'<div class="gallery-preview" ' +
'onclick="window.location.href=\'' + fileUrl + '\'" ' +
'style="cursor:pointer;">' +
thumbnail +
'</div>' +
'<div class="gallery-info">' +
'<span class="gallery-file-name">' + file + '</span>' +
'</div>' +
'</div>';
});
html += '</div>';
galleryContainer.innerHTML = html;
}
function toggleViewMode() {
if (viewMode === 'list') {
viewMode = 'gallery';
document.getElementById("listViewContainer").style.display = "none";
renderGalleryView();
document.getElementById("galleryViewContainer").style.display = "block";
document.getElementById("toggleBtn").textContent = "Switch to List View";
} else {
viewMode = 'list';
document.getElementById("galleryViewContainer").style.display = "none";
document.getElementById("listViewContainer").style.display = "block";
document.getElementById("toggleBtn").textContent = "Switch to Gallery View";
}
} }
</script> </script>
<script src="/js/sharedFolderView.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -73,33 +73,55 @@ 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 doesnt 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);
@@ -110,7 +132,7 @@ class UploadController {
exit; exit;
} }
// Otherwise, for full upload success, set a flash message and redirect. // fullupload redirect
$_SESSION['upload_message'] = "File uploaded successfully."; $_SESSION['upload_message'] = "File uploaded successfully.";
exit; exit;
} }

View File

@@ -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');
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) { $setupMode = false;
// Allow initial admin creation without session or CSRF checks. if (
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; $receivedToken = trim($headersArr['x-csrf-token'] ?? '');
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403); // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
echo json_encode(["error" => "Invalid CSRF token"]); if ($receivedToken !== $_SESSION['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';");
// Ratelimit // 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,7 +913,7 @@ class UserController
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
); );
// Pendinglogin 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;
@@ -906,13 +926,12 @@ 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) ?: [];
@@ -920,39 +939,32 @@ class UserController
$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);
// Reissue 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,7 +972,16 @@ 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;
} }

View File

@@ -5,6 +5,23 @@ require_once PROJECT_ROOT . '/config/config.php';
class AdminModel class AdminModel
{ {
/**
* Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes.
*
* @param string $val
* @return int
*/
private static function parseSize(string $val): int
{
$unit = strtolower(substr($val, -1));
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY');
switch ($unit) {
case 'g': return $num * 1024 ** 3;
case 'm': return $num * 1024 ** 2;
case 'k': return $num * 1024;
default: return $num;
}
}
/** /**
* Updates the admin configuration file. * Updates the admin configuration file.
@@ -24,6 +41,28 @@ class AdminModel
return ["error" => "Incomplete OIDC configuration."]; return ["error" => "Incomplete OIDC configuration."];
} }
// Ensure enableWebDAV flag is boolean (default to false if missing)
$configUpdate['enableWebDAV'] = isset($configUpdate['enableWebDAV'])
? (bool)$configUpdate['enableWebDAV']
: false;
// Validate sharedMaxUploadSize if provided
if (isset($configUpdate['sharedMaxUploadSize'])) {
$sms = filter_var(
$configUpdate['sharedMaxUploadSize'],
FILTER_VALIDATE_INT,
["options" => ["min_range" => 1]]
);
if ($sms === false) {
return ["error" => "Invalid sharedMaxUploadSize."];
}
$totalBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
if ($sms > $totalBytes) {
return ["error" => "sharedMaxUploadSize must be ≤ TOTAL_UPLOAD_SIZE."];
}
$configUpdate['sharedMaxUploadSize'] = $sms;
}
// Convert configuration to JSON. // Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) { if ($plainTextConfig === false) {
@@ -59,7 +98,8 @@ class AdminModel
* *
* @return array The configuration array, or defaults if not found. * @return array The configuration array, or defaults if not found.
*/ */
public static function getConfig(): array { public static function getConfig(): array
{
$configFile = USERS_DIR . 'adminConfig.json'; $configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) { if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile); $encryptedContent = file_get_contents($configFile);
@@ -73,9 +113,8 @@ class AdminModel
$config = []; $config = [];
} }
// Normalize login options. // Normalize login options if missing
if (!isset($config['loginOptions'])) { if (!isset($config['loginOptions'])) {
// Create loginOptions array from top-level keys if missing.
$config['loginOptions'] = [ $config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false, 'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false, 'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
@@ -89,12 +128,22 @@ class AdminModel
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin']; $config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
} }
// Default values for other keys
if (!isset($config['globalOtpauthUrl'])) { if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = ""; $config['globalOtpauthUrl'] = "";
} }
if (!isset($config['header_title']) || empty($config['header_title'])) { if (!isset($config['header_title']) || empty($config['header_title'])) {
$config['header_title'] = "FileRise"; $config['header_title'] = "FileRise";
} }
if (!isset($config['enableWebDAV'])) {
$config['enableWebDAV'] = false;
}
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
if (!isset($config['sharedMaxUploadSize'])) {
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE));
$config['sharedMaxUploadSize'] = $defaultSms;
}
return $config; return $config;
} else { } else {
// Return defaults. // Return defaults.
@@ -111,7 +160,9 @@ class AdminModel
'disableBasicAuth' => false, 'disableBasicAuth' => false,
'disableOIDCLogin' => false 'disableOIDCLogin' => false
], ],
'globalOtpauthUrl' => "" 'globalOtpauthUrl' => "",
'enableWebDAV' => false,
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
]; ];
} }
} }

View File

@@ -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) {
@@ -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;
@@ -58,7 +61,8 @@ class AuthModel {
* @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)) {
@@ -75,7 +79,8 @@ 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);
} }
@@ -85,7 +90,8 @@ class AuthModel {
* @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;
@@ -105,7 +111,8 @@ class AuthModel {
* @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];
}
} }

192
start.sh
View File

@@ -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) Tokenkey warning
if [ "$PERSISTENT_TOKENS_KEY" = "default_please_change_this_key" ]; then if [ "${PERSISTENT_TOKENS_KEY}" = "default_please_change_this_key" ]; then
echo "⚠️ WARNING: Using default persistent tokens key. Please override PERSISTENT_TOKENS_KEY for production." echo "⚠️ WARNING: Using default persistent tokens key—override for production."
fi fi
# Update config.php based on environment variables # 2) Update config.php based on environment variables
CONFIG_FILE="/var/www/config/config.php" CONFIG_FILE="/var/www/config/config.php"
if [ -f "$CONFIG_FILE" ]; then if [ -f "${CONFIG_FILE}" ]; then
echo "🔄 Updating config.php based on environment variables..." echo "🔄 Updating config.php from env vars..."
if [ -n "$TIMEZONE" ]; then [ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
echo " Setting TIMEZONE to $TIMEZONE" [ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '$TIMEZONE');|" "$CONFIG_FILE" if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
fi sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
if [ -n "$DATE_TIME_FORMAT" ]; then
echo "🔄 Setting DATE_TIME_FORMAT to $DATE_TIME_FORMAT"
sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '$DATE_TIME_FORMAT');|" "$CONFIG_FILE"
fi
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then
echo "🔄 Setting TOTAL_UPLOAD_SIZE to $TOTAL_UPLOAD_SIZE"
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '$TOTAL_UPLOAD_SIZE');|" "$CONFIG_FILE"
fi
if [ -n "$SECURE" ]; then
echo "🔄 Setting SECURE to $SECURE"
sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '$SECURE';|" "$CONFIG_FILE"
fi
if [ -n "$SHARE_URL" ]; then
echo "🔄 Setting SHARE_URL to $SHARE_URL"
sed -i "s|define('SHARE_URL',[[:space:]]*'[^']*');|define('SHARE_URL', '$SHARE_URL');|" "$CONFIG_FILE"
fi fi
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
[ -n "${SHARE_URL:-}" ] && sed -i "s|define('SHARE_URL',[[:space:]]*'[^']*');|define('SHARE_URL', '${SHARE_URL}');|" "${CONFIG_FILE}"
fi fi
# Ensure the PHP configuration directory exists # 2.1) Prepare metadata/log for Apache logs
mkdir -p /var/www/metadata/log
chown www-data:www-data /var/www/metadata/log
chmod 775 /var/www/metadata/log
mkdir -p /var/www/sessions
chown www-data:www-data /var/www/sessions
chmod 700 /var/www/sessions
# 2.2) Prepare other dynamic dirs
for d in uploads users metadata; do
tgt="/var/www/${d}"
mkdir -p "${tgt}"
chown www-data:www-data "${tgt}"
chmod 775 "${tgt}"
done
# 3) Ensure PHP config dir & set upload limits
mkdir -p /etc/php/8.3/apache2/conf.d mkdir -p /etc/php/8.3/apache2/conf.d
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
# Update PHP upload limits at runtime if TOTAL_UPLOAD_SIZE is set. echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then cat > /etc/php/8.3/apache2/conf.d/99-custom.ini <<EOF
echo "🔄 Updating PHP upload limits with TOTAL_UPLOAD_SIZE=$TOTAL_UPLOAD_SIZE" upload_max_filesize = ${TOTAL_UPLOAD_SIZE}
echo "upload_max_filesize = $TOTAL_UPLOAD_SIZE" > /etc/php/8.3/apache2/conf.d/99-custom.ini post_max_size = ${TOTAL_UPLOAD_SIZE}
echo "post_max_size = $TOTAL_UPLOAD_SIZE" >> /etc/php/8.3/apache2/conf.d/99-custom.ini EOF
fi fi
# Update Apache LimitRequestBody based on TOTAL_UPLOAD_SIZE if set. # 4) Adjust Apache LimitRequestBody
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
size_str=$(echo "$TOTAL_UPLOAD_SIZE" | tr '[:upper:]' '[:lower:]') # convert to bytes
factor=1 size_str=$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')
case "${size_str: -1}" in case "${size_str: -1}" in
g) g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
factor=$((1024*1024*1024)) m) factor=$((1024*1024)); num=${size_str%m} ;;
size_num=${size_str%g} k) factor=1024; num=${size_str%k} ;;
;; *) factor=1; num=${size_str} ;;
m)
factor=$((1024*1024))
size_num=${size_str%m}
;;
k)
factor=1024
size_num=${size_str%k}
;;
*)
size_num=$size_str
;;
esac esac
LIMIT_REQUEST_BODY=$((size_num * factor)) LIMIT_REQUEST_BODY=$(( num * factor ))
echo "🔄 Setting Apache LimitRequestBody to $LIMIT_REQUEST_BODY bytes (from TOTAL_UPLOAD_SIZE=$TOTAL_UPLOAD_SIZE)" echo "🔄 Setting Apache LimitRequestBody to ${LIMIT_REQUEST_BODY} bytes"
cat <<EOF > /etc/apache2/conf-enabled/limit_request_body.conf cat > /etc/apache2/conf-enabled/limit_request_body.conf <<EOF
<Directory "/var/www/public"> <Directory "/var/www/public">
LimitRequestBody $LIMIT_REQUEST_BODY LimitRequestBody ${LIMIT_REQUEST_BODY}
</Directory> </Directory>
EOF EOF
fi fi
# Set Apache Timeout (default is 300 seconds) # 5) Configure Apache timeout (600s)
echo "🔄 Setting Apache Timeout to 600 seconds" cat > /etc/apache2/conf-enabled/timeout.conf <<EOF
cat <<EOF > /etc/apache2/conf-enabled/timeout.conf
Timeout 600 Timeout 600
EOF EOF
echo "🔥 Final Apache Timeout configuration:" # 6) Override ports if provided
cat /etc/apache2/conf-enabled/timeout.conf if [ -n "${HTTP_PORT:-}" ]; then
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
# Update Apache ports if environment variables are provided sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf
if [ -n "$HTTP_PORT" ]; then fi
echo "🔄 Setting Apache HTTP port to $HTTP_PORT" if [ -n "${HTTPS_PORT:-}" ]; then
sed -i "s/^Listen 80$/Listen $HTTP_PORT/" /etc/apache2/ports.conf sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:$HTTP_PORT>/" /etc/apache2/sites-available/000-default.conf
fi fi
if [ -n "$HTTPS_PORT" ]; then # 7) Set ServerName
echo "🔄 Setting Apache HTTPS port to $HTTPS_PORT" if [ -n "${SERVER_NAME:-}" ]; then
sed -i "s/^Listen 443$/Listen $HTTPS_PORT/" /etc/apache2/ports.conf echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
fi
# Update Apache ServerName if environment variable is provided
if [ -n "$SERVER_NAME" ]; then
echo "🔄 Setting Apache ServerName to $SERVER_NAME"
echo "ServerName $SERVER_NAME" >> /etc/apache2/apache2.conf
else else
echo "🔄 Setting Apache ServerName to default: FileRise"
echo "ServerName FileRise" >> /etc/apache2/apache2.conf echo "ServerName FileRise" >> /etc/apache2/apache2.conf
fi fi
echo "Final /etc/apache2/ports.conf content:" # 8) Prepare dynamic data directories with least privilege
cat /etc/apache2/ports.conf for d in uploads users metadata; do
tgt="/var/www/${d}"
mkdir -p "${tgt}"
chown www-data:www-data "${tgt}"
chmod 775 "${tgt}"
done
echo "📁 Web app is served from /var/www/public." # 9) Initialize persistent files if absent
# Ensure the uploads folder exists in /var/www
mkdir -p /var/www/uploads
echo "🔑 Fixing permissions for /var/www/uploads..."
chown -R ${PUID:-99}:${PGID:-100} /var/www/uploads
chmod -R 775 /var/www/uploads
# Ensure the users folder exists in /var/www
mkdir -p /var/www/users
echo "🔑 Fixing permissions for /var/www/users..."
chown -R ${PUID:-99}:${PGID:-100} /var/www/users
chmod -R 775 /var/www/users
# Ensure the metadata folder exists in /var/www
mkdir -p /var/www/metadata
echo "🔑 Fixing permissions for /var/www/metadata..."
chown -R ${PUID:-99}:${PGID:-100} /var/www/metadata
chmod -R 775 /var/www/metadata
# Create users.txt only if it doesn't already exist (preserving persistent data)
if [ ! -f /var/www/users/users.txt ]; then if [ ! -f /var/www/users/users.txt ]; then
echo " users.txt not found in persistent storage; creating new file..."
echo "" > /var/www/users/users.txt echo "" > /var/www/users/users.txt
chown ${PUID:-99}:${PGID:-100} /var/www/users/users.txt chown www-data:www-data /var/www/users/users.txt
chmod 664 /var/www/users/users.txt chmod 664 /var/www/users/users.txt
else
echo " users.txt already exists; preserving persistent data."
fi fi
# Create createdTags.json only if it doesn't already exist (preserving persistent data)
if [ ! -f /var/www/metadata/createdTags.json ]; then if [ ! -f /var/www/metadata/createdTags.json ]; then
echo " createdTags.json not found in persistent storage; creating new file..."
echo "[]" > /var/www/metadata/createdTags.json echo "[]" > /var/www/metadata/createdTags.json
chown ${PUID:-99}:${PGID:-100} /var/www/metadata/createdTags.json chown www-data:www-data /var/www/metadata/createdTags.json
chmod 664 /var/www/metadata/createdTags.json chmod 664 /var/www/metadata/createdTags.json
else
echo " createdTags.json already exists; preserving persistent data."
fi fi
# Optionally, fix permissions for the rest of /var/www
echo "🔑 Fixing permissions for /var/www..."
find /var/www -type f -exec chmod 664 {} \;
find /var/www -type d -exec chmod 775 {} \;
chown -R ${PUID:-99}:${PGID:-100} /var/www
echo "🔥 Final PHP configuration (90-custom.ini):"
cat /etc/php/8.3/apache2/conf.d/90-custom.ini
echo "🔥 Final Apache configuration (limit_request_body.conf):"
cat /etc/apache2/conf-enabled/limit_request_body.conf
echo "🔥 Starting Apache..." echo "🔥 Starting Apache..."
exec apachectl -D FOREGROUND exec apachectl -D FOREGROUND

View File

View File

@@ -1,7 +0,0 @@
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Options -Indexes

View File

View File

@@ -1,3 +0,0 @@
<Files "users.txt">
Require all denied
</Files>