Compare commits

...

33 Commits

Author SHA1 Message Date
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
Ryan
d23d5b7f3f Added WebDAV Support & curl 2025-04-21 11:12:42 -04:00
Ryan
a48ba09f02 Add WebDAV support with user folderOnly restrictions 2025-04-21 10:39:55 -04:00
Ryan
61357af203 Fetch URL fixes, Extended “Remember Me” cookie behavior, submitLogin() overhaul 2025-04-19 17:53:01 -04:00
Ryan
e390a35e8a Gallery View add selection actions and search filtering 2025-04-18 02:58:30 -04:00
Ryan
7e50ba1f70 test pipeline 2025-04-18 00:52:39 -04:00
Ryan
cc41f8cc95 update sync 2025-04-18 00:51:51 -04:00
Ryan
7c31b9689f update changelog & test pipeline 2025-04-18 00:43:33 -04:00
Ryan
461921b7bc Remember me adjustment 2025-04-18 00:40:17 -04:00
Ryan
3b58123584 User Panel added API Docs link 2025-04-17 06:45:00 -04:00
Ryan
cd9d7eb0ba HTML wrapper that pulls in Redoc from the CDN 2025-04-17 06:28:05 -04:00
Ryan
c0c8d68dc4 mark openapi.json & api.html as documentation 2025-04-17 06:11:27 -04:00
Ryan
2dfcb4062f Generate OpenAPI spec and API HTML docs 2025-04-17 06:04:15 -04:00
46 changed files with 5351 additions and 974 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

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
public/api.html linguist-documentation
public/openapi.json linguist-documentation
resources/ export-ignore
.github/ export-ignore

43
.github/workflows/sync-changelog.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Sync Changelog to Docker Repo
on:
push:
paths:
- 'CHANGELOG.md'
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout FileRise
uses: actions/checkout@v4
with:
path: file-rise
- name: Checkout filerise-docker
uses: actions/checkout@v4
with:
repository: error311/filerise-docker
token: ${{ secrets.PAT_TOKEN }}
path: docker-repo
- name: Copy CHANGELOG.md
run: |
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md
- name: Commit & push
working-directory: docker-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore: sync CHANGELOG.md from FileRise"
git push origin main
fi

View File

@@ -1,5 +1,189 @@
# Changelog
## 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.html`) 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.html">` so session cookies authenticate automatically
- Close control uses the existing `.editor-close-btn` for consistent styling and hover effects
## 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
### Added
- **`src/webdav/CurrentUser.php`**
Introduces a `CurrentUser` singleton to capture and expose the authenticated WebDAV username for use in other components.
### Changed
- **`src/webdav/FileRiseDirectory.php`**
Constructor now takes three parameters (`$path`, `$user`, `$folderOnly`).
Implements “folderonly” mode: nonadmin users only see their own subfolder under the uploads root.
Passes the current user through to `FileRiseFile` so that uploads/deletions are attributed correctly.
- **`src/webdav/FileRiseFile.php`**
Uses `CurrentUser::get()` when writing metadata to populate the `uploader` field.
Metadata helper (`updateMetadata`) now records both upload and modified timestamps along with the actual username.
- **`public/webdav.php`**
Adds a headershim at the top to pull BasicAuth credentials out of `Authorization` for all HTTP methods.
In the auth callback, sets the `CurrentUser` for the rest of the request.
- Admins & unrestricted users see the full `/uploads` directory.
- “Folderonly” users are scoped to `/uploads/{username}`.
Configures SabreDAV with the new `FileRiseDirectory($rootPath, $user, $folderOnly)` signature and sets the base URI to `/webdav.php/`.
## Changes 4/19/2025 v1.2.1
- **Extended “Remember Me” cookie behavior**
In `AuthController::finalizeLogin()`, after setting `remember_me_token` reissued the PHP session cookie with the same 30day expiry and called `session_regenerate_id(true)`.
- **Fetch URL fixes**
Changed all frontend `fetch("api/…")` calls to absolute paths `fetch("/api/…")` to avoid relativepath 404/403 issues.
- **CSRF token refresh**
Updated `submitLogin()` and both TOTP submission handlers to `async/await` a fresh CSRF token from `/api/auth/token.php` (with `credentials: "include"`) immediately before any POST.
- **submitLogin() overhaul**
Refactored to:
1. Fetch CSRF
2. POST credentials to `/api/auth/auth.php`
3. On `totp_required`, refetch CSRF *again* before calling `openTOTPLoginModal()`
4. Handle full logins vs. TOTP flows cleanly.
- **TOTP handlers update**
In both the “Confirm TOTP” button flow and the autosubmit on 6digit input:
- Refreshed CSRF token before every `/api/totp_verify.php` call
- Checked `response.ok` before parsing JSON
- Improved `.catch` error handling
- **verifyTOTP() endpoint enhancement**
Inside the **pendinglogin** branch of `verifyTOTP()`:
- Pulled `$_SESSION['pending_login_remember_me']`
- If true, wrote the persistent token store, set `remember_me_token`, reissued the session cookie, and regenerated the session ID
- Cleaned up pending session variables
---
## Changes 4/18/2025
### fileListView.js
- Seed and persist `itemsPerPage` from `localStorage`
- Use `window.itemsPerPage` for pagination in gallery
- Enable search input filtering in gallery mode
- Always rerender the viewtoggle button on gallery load
- Restore percard action buttons (download, edit, rename, share)
- Assign real `value` to checkboxes and call `updateFileActionButtons()` on change
- Update `changePage` and `changeItemsPerPage` to respect `viewMode`
### fileTags.js
- Import `renderFileTable` and `renderGalleryView`
- Rerender the list after saving a singlefile tag
- Rerender the list after saving multifile tags
---
## Changes 4/17/2025
- Generate OpenAPI spec and API HTML docs
- Fully autogenerated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
- .gitattributes added to mark (`openapi.json`) & (`api.html`) as documentation.
- User Panel added API Docs link.
- Adjusted remember_me_token.
- Test pipeline
---
## Changes 4/16 Refactor API endpoints and modularize controllers and models
- Reorganized project structure to separate API logic into dedicated controllers and models:

View File

@@ -6,12 +6,9 @@
FROM ubuntu:24.04 AS appsource
RUN apt-get update && \
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
# **Copy the FileRise source** (where your composer.json lives)
COPY . /var/www
#############################
@@ -19,78 +16,118 @@ COPY . /var/www
#############################
FROM composer:2 AS composer
WORKDIR /app
# **Copy composer files from the source** and install
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
#############################
FROM ubuntu:24.04
LABEL by=error311
# Set basic environment variables
ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \
LC_ALL=C.UTF-8 \
LANG=en_US.UTF-8 \
LANGUAGE=en_US.UTF-8 \
TERM=xterm \
UPLOAD_MAX_FILESIZE=5G \
POST_MAX_SIZE=5G \
TOTAL_UPLOAD_SIZE=5G \
PERSISTENT_TOKENS_KEY=default_please_change_this_key
ARG PUID=99
ARG PGID=100
LC_ALL=C.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 TERM=xterm \
UPLOAD_MAX_FILESIZE=5G POST_MAX_SIZE=5G TOTAL_UPLOAD_SIZE=5G \
PERSISTENT_TOKENS_KEY=default_please_change_this_key \
PUID=99 PGID=100
# Install Apache, PHP, and required extensions
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
apache2 php php-json php-curl php-zip php-mbstring php-gd \
apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
ca-certificates curl git openssl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
# Fix www-data UID/GID
# Remap www-data to the PUID/PGID provided for safe bind mounts
RUN set -eux; \
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u ${PUID} www-data || true; fi; \
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g ${PGID} www-data || true; fi; \
usermod -g ${PGID} www-data
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 2>/dev/null || true; fi; \
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 --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
RUN chown -R www-data:www-data /var/www && chmod -R 775 /var/www
# Secure permissions: code read-only, only data dirs writable
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.
RUN cd /var/www/public && ln -s ../uploads uploads
# Configure Apache
# Apache site configuration
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>
# Global settings
TraceEnable off
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5
Timeout 60
ServerAdmin webmaster@localhost
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"
</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">
AllowOverride All
Require all granted
DirectoryIndex index.php index.html
DirectoryIndex index.html index.php
</Directory>
ErrorLog /var/log/apache2/error.log
CustomLog /var/log/apache2/access.log combined
# Deny access to hidden files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
ErrorLog /var/www/metadata/log/error.log
CustomLog /var/www/metadata/log/access.log combined
</VirtualHost>
EOF
# Enable the rewrite and headers modules
# Enable required modules
RUN a2enmod rewrite headers
# Expose ports and set up start script
EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh

View File

@@ -1,5 +1,6 @@
MIT License
Copyright (c) 2024 SeNS
Copyright (c) 2025 FileRise
Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@@ -1,7 +1,7 @@
# FileRise
**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:**
@@ -20,6 +20,10 @@ 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.
- 🔌 **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.
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal no need to download just to see them. Edit text/code files right in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers. Great for config files or notes tweak and save changes without leaving FileRise.
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using our indexed real-time search. Easily switch to Advanced Search mode to enable fuzzy matching not only across file names, tags, and uploader fields but also within the content of text files—helping you find that “important” document even if you make a typo or need to search deep within the file.
@@ -28,11 +32,11 @@ Upload, organize, and share files through a sleek web interface. **FileRise** is
- 🎨 **Responsive UI (Dark/Light Mode):** FileRise is mobile-friendly out of the box manage files from your phone or tablet with a responsive layout. Choose between Dark mode or Light theme, or let it follow your system preference. The interface remembers your preferences (layout, items per page, last visited folder, etc.) for a personalized experience each time.
- 🌐 **Internationalization & Localization:** FileRise supports multiple languages via an integrated i18n system. Users can switch languages through a user panel dropdown, and their choice is saved in local storage for a consistent experience across sessions. Currently available in English, Spanish, and French—please report any translation issues you encounter.
- 🌐 **Internationalization & Localization:** FileRise supports multiple languages via an integrated i18n system. Users can switch languages through a user panel dropdown, and their choice is saved in local storage for a consistent experience across sessions. Currently available in English, Spanish, French & German—please report any translation issues you encounter.
- 🗑️ **Trash & File Recovery:** Mistakenly deleted files? No worries deleted items go to the Trash instead of immediate removal. Admins can restore files from Trash or empty it to free space. FileRise auto-purges old trash entries (default 3 days) to keep your storage tidy.
- ⚙️ **Lightweight & Self-Contained:** FileRise runs on PHP 8.1+ with no external database required data is stored in files (users, metadata) for simplicity. Its a single-folder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our pre-built image for a hassle-free setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
- ⚙️ **Lightweight & SelfContained:** FileRise runs on PHP 8.1+ with no external database required data is stored in files (users, metadata) for simplicity. Its a singlefolder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our prebuilt image for a hasslefree setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
(For a full list of features and detailed changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [changelog](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or the [releases](https://github.com/error311/FileRise/releases) pages.)
@@ -58,8 +62,6 @@ If you have Docker installed, you can get FileRise up and running in minutes:
docker pull error311/filerise-docker:latest
```
*(For Apple Silicon (M1/M2) users, use --platform linux/amd64 tag until multi-arch support is added.)*
- **Run a container:**
``` bash
@@ -113,9 +115,9 @@ If you prefer to run FileRise on a traditional web server (LAMP stack or similar
git clone https://github.com/error311/FileRise.git
```
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/public`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.) If you skip this, FileRise will still work, but OIDC login wont be available.
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist):
@@ -145,6 +147,51 @@ Now navigate to the FileRise URL in your browser. On first load, youll be pro
---
## Quickstart: Mount via WebDAV
Once FileRise is running, you must enable WebDAV in admin panel to access it.
```bash
# Linux (GVFS/GIO)
gio mount dav://demo@your-host/webdav.php/
# macOS (Finder → Go → Connect to Server…)
dav://demo@your-host/webdav.php/
```
### Windows (File Explorer)
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
- Choose a drive letter (e.g., `Z:`).
- In **Folder**, enter:
```text
https://your-host/webdav.php/
```
- Check **Connect using different credentials**, and enter your FileRise username and password.
- Click **Finish**. The drive will now appear under **This PC**.
> **Important:**
> Windows requires HTTPS (SSL) for WebDAV connections by default.
> If your server uses plain HTTP, you must adjust a registry setting:
>
> 1. Open **Registry Editor** (`regedit.exe`).
> 2. Navigate to:
>
> ```text
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
> ```
>
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
> 4. Set its value to `2`.
> 5. Restart the **WebClient** service or reboot your computer.
📖 For a full guide (including SSL setup, HTTP workaround, and troubleshooting), see the [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV).
---
## FAQ / Troubleshooting
- **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHPs `post_max_size` / `upload_max_filesize` are all set high enough. For extremely large files, you might also need to increase max_execution_time in PHP or rely on the resumable upload feature in smaller chunks.
@@ -185,18 +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)
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
### Client-Side Libraries
- **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)
- **[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.
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) For indexed, fuzzy searching.
---
## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
---
## 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!

View File

@@ -6,6 +6,7 @@
"jumbojett/openid-connect-php": "^1.0.0",
"phpseclib/phpseclib": "~3.0.7",
"robthree/twofactorauth": "^3.0",
"endroid/qr-code": "^5.0"
"endroid/qr-code": "^5.0",
"sabre/dav": "^4.4"
}
}

497
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6b70aec0c1830ebb2b8f9bb625b04a22",
"content-hash": "3a9b8d9fcfdaaa865ba03eab392e88fd",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -451,6 +451,56 @@
],
"time": "2024-12-14T21:12:59+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "robthree/twofactorauth",
"version": "v3.0.2",
@@ -531,6 +581,451 @@
}
],
"time": "2024-10-24T15:14:25+00:00"
},
{
"name": "sabre/dav",
"version": "4.7.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/dav.git",
"reference": "074373bcd689a30bcf5aaa6bbb20a3395964ce7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/dav/zipball/074373bcd689a30bcf5aaa6bbb20a3395964ce7a",
"reference": "074373bcd689a30bcf5aaa6bbb20a3395964ce7a",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-dom": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"ext-spl": "*",
"lib-libxml": ">=2.7.0",
"php": "^7.1.0 || ^8.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"sabre/event": "^5.0",
"sabre/http": "^5.0.5",
"sabre/uri": "^2.0",
"sabre/vobject": "^4.2.1",
"sabre/xml": "^2.0.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.19",
"monolog/monolog": "^1.27 || ^2.0",
"phpstan/phpstan": "^0.12 || ^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"ext-curl": "*",
"ext-imap": "*",
"ext-pdo": "*"
},
"bin": [
"bin/sabredav",
"bin/naturalselection"
],
"type": "library",
"autoload": {
"psr-4": {
"Sabre\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "WebDAV Framework for PHP",
"homepage": "http://sabre.io/",
"keywords": [
"CalDAV",
"CardDAV",
"WebDAV",
"framework",
"iCalendar"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/dav/issues",
"source": "https://github.com/fruux/sabre-dav"
},
"time": "2024-10-29T11:46:02+00:00"
},
{
"name": "sabre/event",
"version": "5.1.7",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/event.git",
"reference": "86d57e305c272898ba3c28e9bd3d65d5464587c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/event/zipball/86d57e305c272898ba3c28e9bd3d65d5464587c2",
"reference": "86d57e305c272898ba3c28e9bd3d65d5464587c2",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||^3.63",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/coroutine.php",
"lib/Loop/functions.php",
"lib/Promise/functions.php"
],
"psr-4": {
"Sabre\\Event\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "sabre/event is a library for lightweight event-based programming",
"homepage": "http://sabre.io/event/",
"keywords": [
"EventEmitter",
"async",
"coroutine",
"eventloop",
"events",
"hooks",
"plugin",
"promise",
"reactor",
"signal"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/event/issues",
"source": "https://github.com/fruux/sabre-event"
},
"time": "2024-08-27T11:23:05+00:00"
},
{
"name": "sabre/http",
"version": "5.1.12",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/http.git",
"reference": "dedff73f3995578bc942fa4c8484190cac14f139"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/http/zipball/dedff73f3995578bc942fa4c8484190cac14f139",
"reference": "dedff73f3995578bc942fa4c8484190cac14f139",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-curl": "*",
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/event": ">=4.0 <6.0",
"sabre/uri": "^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||^3.63",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"ext-curl": " to make http requests with the Client class"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\HTTP\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "The sabre/http library provides utilities for dealing with http requests and responses. ",
"homepage": "https://github.com/fruux/sabre-http",
"keywords": [
"http"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/http/issues",
"source": "https://github.com/fruux/sabre-http"
},
"time": "2024-08-27T16:07:41+00:00"
},
{
"name": "sabre/uri",
"version": "2.3.4",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "b76524c22de90d80ca73143680a8e77b1266c291"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/b76524c22de90d80ca73143680a8e77b1266c291",
"reference": "b76524c22de90d80ca73143680a8e77b1266c291",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.63",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2024-08-27T12:18:16+00:00"
},
{
"name": "sabre/vobject",
"version": "4.5.7",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "ff22611a53782e90c97be0d0bc4a5f98a5c0a12c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/ff22611a53782e90c97be0d0bc4a5f98a5c0a12c",
"reference": "ff22611a53782e90c97be0d0bc4a5f98a5c0a12c",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2025-04-17T09:22:48+00:00"
},
{
"name": "sabre/xml",
"version": "2.2.11",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.1 || ^8.0",
"sabre/uri": ">=1.0,<3.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||3.63.2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2024-09-06T07:37:46+00:00"
}
],
"packages-dev": [],

View File

@@ -1,73 +1,61 @@
<?php
// config.php
// Prevent caching
header("Cache-Control: no-cache, must-revalidate");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Pragma: no-cache");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Expires: 0");
header('X-Content-Type-Options: nosniff');
// Security headers
header("X-Content-Type-Options: nosniff");
header('X-Content-Type-Options: nosniff');
header("X-Frame-Options: SAMEORIGIN");
header("Referrer-Policy: no-referrer-when-downgrade");
// Only include Strict-Transport-Security if you are using HTTPS
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
header("X-XSS-Protection: 1; mode=block");
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
}
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
header("X-XSS-Protection: 1; mode=block");
// Define constants.
// Define constants
define('PROJECT_ROOT', dirname(__DIR__));
define('UPLOAD_DIR', '/var/www/uploads/');
define('USERS_DIR', '/var/www/users/');
define('USERS_FILE', 'users.txt');
define('META_DIR', '/var/www/metadata/');
define('META_FILE', 'file_metadata.json');
define('TRASH_DIR', UPLOAD_DIR . 'trash/');
define('TIMEZONE', 'America/New_York');
define('DATE_TIME_FORMAT', 'm/d/y h:iA');
define('TOTAL_UPLOAD_SIZE', '5G');
define('UPLOAD_DIR', '/var/www/uploads/');
define('USERS_DIR', '/var/www/users/');
define('USERS_FILE', 'users.txt');
define('META_DIR', '/var/www/metadata/');
define('META_FILE', 'file_metadata.json');
define('TRASH_DIR', UPLOAD_DIR . 'trash/');
define('TIMEZONE', 'America/New_York');
define('DATE_TIME_FORMAT','m/d/y h:iA');
define('TOTAL_UPLOAD_SIZE','5G');
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
define('PATTERN_FOLDER_NAME', '[\p{L}\p{N}_\-\s\/\\\\]+');
define('REGEX_FILE_NAME', '/^[\p{L}\p{N}\p{M}%\-\.\(\) _]+$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
define('REGEX_FILE_NAME', '/^[\p{L}\p{N}\p{M}%\-\.\(\) _]+$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
date_default_timezone_set(TIMEZONE);
/**
* Encrypts data using AES-256-CBC.
*
* @param string $data The plaintext.
* @param string $encryptionKey The encryption key.
* @return string Base64-encoded string containing IV and ciphertext.
*/
// Encryption helpers
function encryptData($data, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$ivlen = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
$ciphertext = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $ciphertext);
$ivlen = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
$ct = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $ct);
}
/**
* Decrypts data encrypted with AES-256-CBC.
*
* @param string $encryptedData Base64-encoded data containing IV and ciphertext.
* @param string $encryptionKey The encryption key.
* @return string|false The decrypted plaintext or false on failure.
*/
function decryptData($encryptedData, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$data = base64_decode($encryptedData);
$ivlen = openssl_cipher_iv_length($cipher);
$iv = substr($data, 0, $ivlen);
$ciphertext = substr($data, $ivlen);
return openssl_decrypt($ciphertext, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
$data = base64_decode($encryptedData);
$ivlen = openssl_cipher_iv_length($cipher);
$iv = substr($data, 0, $ivlen);
$ct = substr($data, $ivlen);
return openssl_decrypt($ct, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
}
// Load encryption key from environment (override in production).
// Load encryption key
$envKey = getenv('PERSISTENT_TOKENS_KEY');
if ($envKey === false || $envKey === '') {
$encryptionKey = 'default_please_change_this_key';
@@ -76,97 +64,89 @@ if ($envKey === false || $envKey === '') {
$encryptionKey = $envKey;
}
// Helper to load JSON permissions (with optional decryption)
function loadUserPermissions($username)
{
global $encryptionKey;
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
// Try to decrypt the content.
$decryptedContent = decryptData($content, $encryptionKey);
if ($decryptedContent !== false) {
$permissions = json_decode($decryptedContent, true);
} else {
$permissions = json_decode($content, true);
}
if (is_array($permissions) && array_key_exists($username, $permissions)) {
$result = $permissions[$username];
return !empty($result) ? $result : false;
$decrypted = decryptData($content, $encryptionKey);
$json = ($decrypted !== false) ? $decrypted : $content;
$perms = json_decode($json, true);
if (is_array($perms) && isset($perms[$username])) {
return !empty($perms[$username]) ? $perms[$username] : false;
}
}
// Removed error_log() to prevent flooding logs when file is not found.
return false; // Return false if no permissions found.
return false;
}
// Determine whether HTTPS is used.
// Determine HTTPS usage
$envSecure = getenv('SECURE');
if ($envSecure !== false) {
$secure = filter_var($envSecure, FILTER_VALIDATE_BOOLEAN);
} else {
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
}
$secure = ($envSecure !== false)
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
$cookieParams = [
'lifetime' => 7200,
// Choose session lifetime based on "remember me" cookie
$defaultSession = 7200; // 2 hours
$persistentDays = 30 * 24 * 60 * 60; // 30 days
$sessionLifetime = isset($_COOKIE['remember_me_token'])
? $persistentDays
: $defaultSession;
// Configure PHP session cookie and GC
session_set_cookie_params([
'lifetime' => $sessionLifetime,
'path' => '/',
'domain' => '', // Set your domain as needed.
'domain' => '', // adjust if you need a specific domain
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax'
];
// At the very beginning of config.php
/*ini_set('session.save_path', __DIR__ . '/../sessions');
if (!is_dir(__DIR__ . '/../sessions')) {
mkdir(__DIR__ . '/../sessions', 0777, true);
}*/
]);
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params($cookieParams);
ini_set('session.gc_maxlifetime', 7200);
session_start();
}
// CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Auto-login via persistent token.
if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token'])) {
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
$persistentTokens = [];
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
$persistentTokens = json_decode($decryptedContent, true);
if (!is_array($persistentTokens)) {
$persistentTokens = [];
}
// Autologin via persistent token
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
$tokFile = USERS_DIR . 'persistent_tokens.json';
$tokens = [];
if (file_exists($tokFile)) {
$enc = file_get_contents($tokFile);
$dec = decryptData($enc, $encryptionKey);
$tokens = json_decode($dec, true) ?: [];
}
if (isset($persistentTokens[$_COOKIE['remember_me_token']])) {
$tokenData = $persistentTokens[$_COOKIE['remember_me_token']];
if ($tokenData['expiry'] >= time()) {
$token = $_COOKIE['remember_me_token'];
if (!empty($tokens[$token])) {
$data = $tokens[$token];
if ($data['expiry'] >= time()) {
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $tokenData["username"];
// IMPORTANT: Set the folderOnly flag here for auto-login.
$_SESSION["folderOnly"] = loadUserPermissions($tokenData["username"]);
$_SESSION["username"] = $data["username"];
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
$_SESSION["isAdmin"] = !empty($data["isAdmin"]);
} else {
unset($persistentTokens[$_COOKIE['remember_me_token']]);
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
// expired — clean up
unset($tokens[$token]);
file_put_contents($tokFile, encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey), LOCK_EX);
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
}
}
}
// Share URL fallback
define('BASE_URL', 'http://yourwebsite/uploads/');
if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShareUrl = isset($_SERVER['HTTP_HOST'])
? "http://" . $_SERVER['HTTP_HOST'] . "/api/file/share.php"
$defaultShare = isset($_SERVER['HTTP_HOST'])
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
: "http://localhost/api/file/share.php";
} else {
$defaultShareUrl = rtrim(BASE_URL, '/') . "/api/file/share.php";
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
}
define('SHARE_URL', getenv('SHARE_URL') ? getenv('SHARE_URL') : $defaultShareUrl);
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);

View File

@@ -41,6 +41,7 @@ upload_tmp_dir=/tmp
session.gc_maxlifetime=1440
session.gc_probability=1
session.gc_divisor=100
session.save_path = "/var/www/sessions"
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Error Handling / Logging

View File

@@ -15,6 +15,10 @@ DirectoryIndex index.html
Require all denied
</FilesMatch>
<FilesMatch "^(api\.html|openapi\.json)$">
Require valid-user
</FilesMatch>
# -----------------------------
# Enforce HTTPS (optional)
# -----------------------------

20
public/api.html Normal file
View File

@@ -0,0 +1,20 @@
<!-- 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>

View File

@@ -44,6 +44,55 @@ function showToast(msgKey) {
}
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
function openTOTPLoginModal() {
originalOpenTOTPLoginModal();
@@ -95,7 +144,7 @@ function updateLoginOptionsUIFromStorage() {
}
export function loadAdminConfigFunc() {
return fetch("api/admin/getConfig.php", { credentials: "include" })
return fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(response => response.json())
.then(config => {
localStorage.setItem("headerTitle", config.header_title || "FileRise");
@@ -105,7 +154,7 @@ export function loadAdminConfigFunc() {
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
updateLoginOptionsUIFromStorage();
const headerTitleElem = document.querySelector(".header-title h1");
@@ -149,9 +198,9 @@ function updateAuthenticatedUI(data) {
if (data.username) {
localStorage.setItem("username", data.username);
}
if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
}
@@ -198,11 +247,11 @@ function updateAuthenticatedUI(data) {
userPanelBtn.classList.add("btn", "btn-user");
userPanelBtn.setAttribute("data-i18n-title", "user_panel");
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
const adminBtn = document.getElementById("adminPanelBtn");
if (adminBtn) insertAfter(userPanelBtn, adminBtn);
else if (firstButton) insertAfter(userPanelBtn, firstButton);
else headerButtons.appendChild(userPanelBtn);
else headerButtons.appendChild(userPanelBtn);
userPanelBtn.addEventListener("click", openUserPanel);
} else {
userPanelBtn.style.display = "block";
@@ -214,7 +263,7 @@ function updateAuthenticatedUI(data) {
}
function checkAuthentication(showLoginToast = true) {
return sendRequest("api/auth/checkAuth.php")
return sendRequest("/api/auth/checkAuth.php")
.then(data => {
if (data.setup) {
window.setupMode = true;
@@ -228,13 +277,18 @@ function checkAuthentication(showLoginToast = true) {
}
window.setupMode = false;
if (data.authenticated) {
localStorage.setItem("folderOnly", data.folderOnly );
localStorage.setItem("readOnly", data.readOnly );
localStorage.setItem("disableUpload",data.disableUpload);
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
localStorage.setItem("folderOnly", data.folderOnly);
localStorage.setItem("readOnly", data.readOnly);
localStorage.setItem("disableUpload", data.disableUpload);
updateLoginOptionsUIFromStorage();
if (typeof data.totp_enabled !== "undefined") {
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);
return data;
} else {
@@ -251,55 +305,71 @@ function checkAuthentication(showLoginToast = true) {
}
/* ----------------- Authentication Submission ----------------- */
function submitLogin(data) {
async function submitLogin(data) {
setLastLoginData(data);
window.__lastLoginData = data;
sendRequest("api/auth/auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success || response.status === "ok") {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
// Fetch and update permissions, then reload.
sendRequest("api/getUserPermissions.php", "GET")
.then(permissionData => {
if (permissionData && typeof permissionData === "object") {
localStorage.setItem("folderOnly", permissionData.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", permissionData.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", permissionData.disableUpload ? "true" : "false");
}
})
.catch(() => {
// ignore permissionfetch errors
})
.finally(() => {
window.location.reload();
});
} else if (response.totp_required) {
openTOTPLoginModal();
} else if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
const loginButton = document.querySelector("#authForm button[type='submit']");
if (loginButton) {
loginButton.disabled = true;
setTimeout(() => {
loginButton.disabled = false;
showToast("You can now try logging in again.");
}, 30 * 60 * 1000);
try {
// ─── 1) Get CSRF for the initial auth call ───
let res = await fetch("/api/auth/token.php", { credentials: "include" });
if (!res.ok) throw new Error("Could not fetch CSRF token");
window.csrfToken = (await res.json()).csrf_token;
// ─── 2) Send credentials ───
const response = await sendRequest(
"/api/auth/auth.php",
"POST",
data,
{ "X-CSRF-Token": window.csrfToken }
);
// ─── 3a) Full login (no TOTP) ───
if (response.success || response.status === "ok") {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
// … fetch permissions & reload …
try {
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
if (perm && typeof perm === "object") {
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
}
} else {
showToast("Login failed: " + (response.error || "Unknown error"));
} catch { }
return window.location.reload();
}
// ─── 3b) TOTP required ───
if (response.totp_required) {
// **Refresh** CSRF before the TOTP verify call
res = await fetch("/api/auth/token.php", { credentials: "include" });
if (res.ok) {
window.csrfToken = (await res.json()).csrf_token;
}
})
.catch(err => {
// err may be an Error object or a string
let msg = "Unknown error";
if (err && typeof err === "object") {
msg = err.error || err.message || msg;
} else if (typeof err === "string") {
msg = err;
// now open the modal—any totp_verify fetch from here on will use the new token
return openTOTPLoginModal();
}
// ─── 3c) Too many attempts ───
if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
const btn = document.querySelector("#authForm button[type='submit']");
if (btn) {
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
showToast("You can now try logging in again.");
}, 30 * 60 * 1000);
}
showToast(`Login failed: ${msg}`);
});
return;
}
// ─── 3d) Other failures ───
showToast("Login failed: " + (response.error || "Unknown error"));
} catch (err) {
const msg = err.message || err.error || "Unknown error";
showToast(`Login failed: ${msg}`);
}
}
window.submitLogin = submitLogin;
@@ -327,7 +397,7 @@ function closeRemoveUserModal() {
function loadUserList() {
// Updated path: from "getUsers.php" to "api/getUsers.php"
fetch("api/getUsers.php", { credentials: "include" })
fetch("/api/getUsers.php", { credentials: "include" })
.then(response => response.json())
.then(data => {
// Assuming the endpoint returns an array of users.
@@ -368,7 +438,7 @@ function initAuth() {
});
}
document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("api/auth/logout.php", {
fetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
@@ -387,12 +457,12 @@ function initAuth() {
showToast("Username and password are required!");
return;
}
let url = "api/addUser.php";
let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1";
fetch(url, {
fetchWithCsrf(url, {
method: "POST",
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 })
})
.then(response => response.json())
@@ -422,10 +492,10 @@ function initAuth() {
}
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
if (!confirmed) return;
fetch("api/removeUser.php", {
fetchWithCsrf("/api/removeUser.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: usernameToRemove })
})
.then(response => response.json())
@@ -461,10 +531,10 @@ function initAuth() {
return;
}
const data = { oldPassword, newPassword, confirmPassword };
fetch("api/changePassword.php", {
fetchWithCsrf("/api/changePassword.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
})
.then(response => response.json())

View File

@@ -3,8 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js';
const version = "v1.2.0";
// Use t() for the admin panel title. (Make sure t("admin_panel") returns "Admin Panel" in English.)
const version = "v1.2.5"; // Update this version string as needed
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null;
@@ -84,7 +83,7 @@ export function openTOTPLoginModal() {
showToast(t("please_enter_recovery_code"));
return;
}
fetch("api/totp_recover.php", {
fetch("/api/totp_recover.php", {
method: "POST",
credentials: "include",
headers: {
@@ -110,36 +109,47 @@ export function openTOTPLoginModal() {
// TOTP submission
const totpInput = document.getElementById("totpLoginInput");
totpInput.focus();
totpInput.addEventListener("input", function () {
totpInput.addEventListener("input", async function () {
const code = this.value.trim();
if (code.length === 6) {
fetch("api/totp_verify.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_code: code })
})
.then(res => res.json())
.then(json => {
if (json.status === "ok") {
window.location.href = "/index.html";
} else {
showToast(json.message || t("totp_verification_failed"));
this.value = "";
totpLoginModal.style.display = "flex";
totpInput.focus();
}
})
.catch(() => {
showToast(t("totp_verification_failed"));
this.value = "";
totpLoginModal.style.display = "flex";
totpInput.focus();
});
if (code.length !== 6) {
return;
}
const tokenRes = await fetch("/api/auth/token.php", {
credentials: "include"
});
if (!tokenRes.ok) {
showToast(t("totp_verification_failed"));
return;
}
window.csrfToken = (await tokenRes.json()).csrf_token;
const res = await fetch("/api/totp_verify.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_code: code })
});
if (res.ok) {
const json = await res.json();
if (json.status === "ok") {
window.location.href = "/index.html";
return;
}
showToast(json.message || t("totp_verification_failed"));
} else {
showToast(t("totp_verification_failed"));
}
this.value = "";
totpLoginModal.style.display = "flex";
this.focus();
});
} else {
// Re-open existing modal
@@ -166,105 +176,135 @@ export function openUserPanel() {
border-radius: 8px;
position: fixed;
overflow-y: auto;
max-height: 350px !important;
max-height: 400px !important;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
transform: none;
transition: none;
`;
// Retrieve the language setting from local storage, default to English ("en")
const savedLanguage = localStorage.getItem("language") || "en";
if (!userPanelModal) {
userPanelModal = document.createElement("div");
userPanelModal.id = "userPanelModal";
userPanelModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
`;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
`;
userPanelModal.innerHTML = `
<div class="modal-content user-panel-content" style="${modalContentStyles}">
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>${t("user_panel")} (${username})</h3>
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">${t("change_password")}</button>
<fieldset style="margin-bottom: 15px;">
<legend>${t("totp_settings")}</legend>
<div class="form-group">
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
</div>
</fieldset>
<fieldset style="margin-bottom: 15px;">
<legend>${t("language")}</legend>
<div class="form-group">
<label for="languageSelector">${t("select_language")}:</label>
<select id="languageSelector">
<option value="en">${t("english")}</option>
<option value="es">${t("spanish")}</option>
<option value="fr">${t("french")}</option>
<option value="de">${t("german")}</option>
</select>
</div>
</fieldset>
<div class="modal-content user-panel-content" style="${modalContentStyles}">
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>${t("user_panel")} (${username})</h3>
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">
${t("change_password")}
</button>
<fieldset style="margin-bottom: 15px;">
<legend>${t("totp_settings")}</legend>
<div class="form-group">
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
</div>
</fieldset>
<fieldset style="margin-bottom: 15px;">
<legend>${t("language")}</legend>
<div class="form-group">
<label for="languageSelector">${t("select_language")}:</label>
<select id="languageSelector">
<option value="en">${t("english")}</option>
<option value="es">${t("spanish")}</option>
<option value="fr">${t("french")}</option>
<option value="de">${t("german")}</option>
</select>
</div>
</fieldset>
<!-- New API Docs link -->
<div style="margin-bottom: 15px;">
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
${t("api_docs") || "API Docs"}
</button>
</div>
`;
</div>
`;
document.body.appendChild(userPanelModal);
// Close button handler
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;
`;
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.html" 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…
document.getElementById("closeUserPanel").addEventListener("click", () => {
userPanelModal.style.display = "none";
});
// Change Password button
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
document.getElementById("changePasswordModal").style.display = "block";
});
// TOTP checkbox behavior
// TOTP checkbox
const totpCheckbox = document.getElementById("userTOTPEnabled");
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
totpCheckbox.addEventListener("change", function () {
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
const enabled = this.checked;
fetch("api/updateUserPanel.php", {
fetch("/api/updateUserPanel.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_enabled: enabled })
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ totp_enabled: this.checked })
})
.then(r => r.json())
.then(result => {
if (!result.success) {
showToast(t("error_updating_totp_setting") + ": " + result.error);
} else if (enabled) {
openTOTPModal();
}
if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error);
else if (this.checked) openTOTPModal();
})
.catch(() => { showToast(t("error_updating_totp_setting")); });
.catch(() => showToast(t("error_updating_totp_setting")));
});
// Language dropdown initialization
// Language selector
const languageSelector = document.getElementById("languageSelector");
languageSelector.value = savedLanguage;
languageSelector.addEventListener("change", function () {
const selectedLanguage = this.value;
localStorage.setItem("language", selectedLanguage);
setLocale(selectedLanguage);
localStorage.setItem("language", this.value);
setLocale(this.value);
applyTranslations();
});
} else {
// If the modal already exists, update its colors
// Update colors if already exists
userPanelModal.style.backgroundColor = overlayBackground;
const modalContent = userPanelModal.querySelector(".modal-content");
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
}
userPanelModal.style.display = "flex";
}
@@ -347,13 +387,24 @@ export function openTOTPModal() {
closeTOTPModal(true);
});
document.getElementById("confirmTOTPBtn").addEventListener("click", function () {
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
const code = document.getElementById("totpConfirmInput").value.trim();
if (code.length !== 6) {
showToast(t("please_enter_valid_code"));
return;
}
fetch("api/totp_verify.php", {
const tokenRes = await fetch("/api/auth/token.php", {
credentials: "include"
});
if (!tokenRes.ok) {
showToast(t("error_verifying_totp_code"));
return;
}
const { csrf_token } = await tokenRes.json();
window.csrfToken = csrf_token;
const verifyRes = await fetch("/api/totp_verify.php", {
method: "POST",
credentials: "include",
headers: {
@@ -361,36 +412,40 @@ export function openTOTPModal() {
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_code: code })
})
.then(r => r.json())
.then(result => {
if (result.status === 'ok') {
showToast(t("totp_enabled_successfully"));
// After successful TOTP verification, fetch the recovery code
fetch("api/totp_saveCode.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
}
})
.then(r => r.json())
.then(data => {
if (data.status === 'ok' && data.recoveryCode) {
// Show the recovery code in a secure modal
showRecoveryCodeModal(data.recoveryCode);
} else {
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
}
})
.catch(() => { showToast(t("error_generating_recovery_code")); });
closeTOTPModal(false);
} else {
showToast(t("totp_verification_failed") + ": " + (result.message || t("invalid_code")));
}
})
.catch(() => { showToast(t("error_verifying_totp_code")); });
});
if (!verifyRes.ok) {
showToast(t("totp_verification_failed"));
return;
}
const result = await verifyRes.json();
if (result.status !== "ok") {
showToast(result.message || t("totp_verification_failed"));
return;
}
showToast(t("totp_enabled_successfully"));
const saveRes = await fetch("/api/totp_saveCode.php", {
method: "POST",
credentials: "include",
headers: {
"X-CSRF-Token": window.csrfToken
}
});
if (!saveRes.ok) {
showToast(t("error_generating_recovery_code"));
closeTOTPModal(false);
return;
}
const data = await saveRes.json();
if (data.status === "ok" && data.recoveryCode) {
showRecoveryCodeModal(data.recoveryCode);
} else {
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
}
closeTOTPModal(false);
});
// Focus the input and attach enter key listener
@@ -431,7 +486,7 @@ export function openTOTPModal() {
}
function loadTOTPQRCode() {
fetch("api/totp_setup.php", {
fetch("/api/totp_setup.php", {
method: "GET",
credentials: "include",
headers: {
@@ -470,7 +525,7 @@ export function closeTOTPModal(disable = true) {
localStorage.setItem("userTOTPEnabled", "false");
}
// Call endpoint to remove the TOTP secret from the user's record
fetch("api/totp_disable.php", {
fetch("/api/totp_disable.php", {
method: "POST",
credentials: "include",
headers: {
@@ -549,14 +604,14 @@ function showCustomConfirmModal(message) {
noBtn.removeEventListener("click", onNo);
modal.style.display = "none";
}
yesBtn.addEventListener("click", onYes);
noBtn.addEventListener("click", onNo);
});
}
export function openAdminPanel() {
fetch("api/admin/getConfig.php", { credentials: "include" })
fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(response => response.json())
.then(config => {
if (config.header_title) {
@@ -565,6 +620,7 @@ export function openAdminPanel() {
}
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = `
@@ -579,6 +635,7 @@ export function openAdminPanel() {
max-height: 90vh;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
`;
let adminModal = document.getElementById("adminPanelModal");
if (!adminModal) {
@@ -631,6 +688,28 @@ export function openAdminPanel() {
<label for="disableOIDCLogin">${t("disable_oidc_login")}</label>
</div>
</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;">
<legend>${t("oidc_configuration")}</legend>
<div class="form-group">
@@ -666,33 +745,34 @@ export function openAdminPanel() {
`;
document.body.appendChild(adminModal);
// Bind closing events that will use our enhanced close function.
// Bind closing
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
adminModal.addEventListener("click", (e) => {
if (e.target === adminModal) closeAdminPanel();
});
adminModal.addEventListener("click", e => { if (e.target === adminModal) closeAdminPanel(); });
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
// Bind other buttons.
// Bind other buttons
document.getElementById("adminOpenAddUser").addEventListener("click", () => {
toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus();
});
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
if (typeof window.loadUserList === "function") {
window.loadUserList();
}
if (typeof window.loadUserList === "function") window.loadUserList();
toggleVisibility("removeUserModal", true);
});
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
openUserPermissionsModal();
});
// Save handler
document.getElementById("saveAdminSettings").addEventListener("click", () => {
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
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) {
showToast(t("at_least_one_login_method"));
disableOIDCLoginCheckbox.checked = false;
@@ -706,8 +786,8 @@ export function openAdminPanel() {
}
return;
}
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
const newOIDCConfig = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(),
@@ -717,13 +797,18 @@ export function openAdminPanel() {
const disableFormLogin = disableFormLoginCheckbox.checked;
const disableBasicAuth = disableBasicAuthCheckbox.checked;
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
const enableWebDAV = enableWebDAVCheckbox.checked;
const sharedMaxUploadSize = parseInt(sharedMaxUploadSizeInput.value, 10) || 0;
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
sendRequest("api/admin/updateConfig.php", "POST", {
sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: newHeaderTitle,
oidc: newOIDCConfig,
disableFormLogin,
disableBasicAuth,
disableOIDCLogin,
enableWebDAV,
sharedMaxUploadSize,
globalOtpauthUrl
}, { "X-CSRF-Token": window.csrfToken })
.then(response => {
@@ -732,26 +817,32 @@ export function openAdminPanel() {
localStorage.setItem("disableFormLogin", disableFormLogin);
localStorage.setItem("disableBasicAuth", disableBasicAuth);
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
localStorage.setItem("enableWebDAV", enableWebDAV);
localStorage.setItem("sharedMaxUploadSize", sharedMaxUploadSize);
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();
closeAdminPanel();
loadAdminConfigFunc();
} else {
showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error")));
}
})
.catch(() => { });
});
// Enforce login option constraints.
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
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) {
showToast(t("at_least_one_login_method"));
changedCheckbox.checked = false;
@@ -761,13 +852,17 @@ export function openAdminPanel() {
disableBasicAuthCheckbox.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("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
// Capture initial state after the modal loads.
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
captureInitialAdminConfig();
} else {
// Update existing modal and show
adminModal.style.backgroundColor = overlayBackground;
const modalContent = adminModal.querySelector(".modal-content");
if (modalContent) {
@@ -783,6 +878,8 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === 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";
captureInitialAdminConfig();
}
@@ -805,6 +902,8 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "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";
captureInitialAdminConfig();
} else {
@@ -891,7 +990,7 @@ export function openUserPermissionsModal() {
});
});
// Send the permissionsData to the server.
sendRequest("api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success) {
showToast(t("user_permissions_updated_successfully"));
@@ -917,11 +1016,11 @@ function loadUserPermissionsList() {
listContainer.innerHTML = "";
// First, fetch the current permissions from the server.
fetch("api/getUserPermissions.php", { credentials: "include" })
fetch("/api/getUserPermissions.php", { credentials: "include" })
.then(response => response.json())
.then(permissionsData => {
// Then, fetch the list of users.
return fetch("api/getUsers.php", { credentials: "include" })
return fetch("/api/getUsers.php", { credentials: "include" })
.then(response => response.json())
.then(usersData => {
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);

View File

@@ -32,7 +32,7 @@ document.addEventListener("DOMContentLoaded", function () {
const confirmDelete = document.getElementById("confirmDeleteFiles");
if (confirmDelete) {
confirmDelete.addEventListener("click", function () {
fetch("api/file/deleteFiles.php", {
fetch("/api/file/deleteFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -178,7 +178,7 @@ export function handleExtractZipSelected(e) {
// Show the progress modal.
document.getElementById("downloadProgressModal").style.display = "block";
fetch("api/file/extractZip.php", {
fetch("/api/file/extractZip.php", {
method: "POST",
credentials: "include",
headers: {
@@ -245,7 +245,7 @@ document.addEventListener("DOMContentLoaded", function () {
console.log("Download confirmed. Showing progress modal.");
document.getElementById("downloadProgressModal").style.display = "block";
const folder = window.currentFolder || "root";
fetch("api/file/downloadZip.php", {
fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: {
@@ -309,7 +309,7 @@ export async function loadCopyMoveFolderListForModal(dropdownId) {
if (window.userFolderOnly) {
const username = localStorage.getItem("username") || "root";
try {
const response = await fetch("api/folder/getFolderList.php?restricted=1");
const response = await fetch("/api/folder/getFolderList.php?restricted=1");
let folders = await response.json();
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder);
@@ -339,7 +339,7 @@ export async function loadCopyMoveFolderListForModal(dropdownId) {
}
try {
const response = await fetch("api/folder/getFolderList.php");
const response = await fetch("/api/folder/getFolderList.php");
let folders = await response.json();
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder);
@@ -397,7 +397,7 @@ document.addEventListener("DOMContentLoaded", function () {
showToast("Error: Cannot copy files to the same folder.");
return;
}
fetch("api/file/copyFiles.php", {
fetch("/api/file/copyFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -448,7 +448,7 @@ document.addEventListener("DOMContentLoaded", function () {
showToast("Error: Cannot move files to the same folder.");
return;
}
fetch("api/file/moveFiles.php", {
fetch("/api/file/moveFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -514,7 +514,7 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
const folderUsed = window.fileFolder;
fetch("api/file/renameFile.php", {
fetch("/api/file/renameFile.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -96,7 +96,7 @@ export function folderDropHandler(event) {
return;
}
if (!dragData || !dragData.fileName) return;
fetch("api/file/moveFiles.php", {
fetch("/api/file/moveFiles.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -160,7 +160,7 @@ export function saveFile(fileName, folder) {
content: editor.getValue(),
folder: folderUsed
};
fetch("api/file/saveFile.php", {
fetch("/api/file/saveFile.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -20,9 +20,12 @@ import { openTagModal, openMultiTagModal } from './fileTags.js';
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
window.itemsPerPage = window.itemsPerPage || 10;
window.itemsPerPage = parseInt(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
10
);
window.currentPage = window.currentPage || 1;
window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery"
window.viewMode = localStorage.getItem("viewMode") || "table";
// Global flag for advanced search mode.
window.advancedSearchEnabled = false;
@@ -193,7 +196,7 @@ export function loadFileList(folderParam) {
fileListContainer.style.visibility = "hidden";
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
return fetch("api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
return fetch("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
.then(response => {
if (response.status === 401) {
showToast("Session expired. Please log in again.");
@@ -407,33 +410,89 @@ export function renderGalleryView(folder, container) {
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
// Use the current global column value (default to 3).
const numColumns = window.galleryColumns || 3;
// pagination settings
const itemsPerPage = window.itemsPerPage;
let currentPage = window.currentPage || 1;
const totalFiles = filteredFiles.length;
const totalPages = Math.ceil(totalFiles / itemsPerPage);
if (currentPage > totalPages) {
currentPage = totalPages || 1;
window.currentPage = currentPage;
}
// --- Insert slider controls ---
const sliderHTML = `
<div class="gallery-slider" style="margin: 10px; text-align: center;">
<label for="galleryColumnsSlider" style="margin-right: 5px;">${t('columns')}:</label>
<input type="range" id="galleryColumnsSlider" min="1" max="6" value="${numColumns}" style="vertical-align: middle;">
// --- Top controls: search + pagination + items-per-page ---
let galleryHTML = buildSearchAndPaginationControls({
currentPage,
totalPages,
searchTerm: window.currentSearchTerm || ""
});
// wire up search input just like table view
setTimeout(() => {
const searchInput = document.getElementById("searchInput");
if (searchInput) {
searchInput.addEventListener("input", debounce(() => {
window.currentSearchTerm = searchInput.value;
window.currentPage = 1;
renderGalleryView(folder);
// keep caret at end
setTimeout(() => {
const f = document.getElementById("searchInput");
if (f) {
f.focus();
const len = f.value.length;
f.setSelectionRange(len, len);
}
}, 0);
}, 300));
}
}, 0);
// --- Column slider ---
const numColumns = window.galleryColumns || 3;
galleryHTML += `
<div class="gallery-slider" style="margin:10px; text-align:center;">
<label for="galleryColumnsSlider" style="margin-right:5px;">
${t('columns')}:
</label>
<input type="range" id="galleryColumnsSlider" min="1" max="6"
value="${numColumns}" style="vertical-align:middle;">
<span id="galleryColumnsValue">${numColumns}</span>
</div>
`;
// Set up the grid container using the slider's current value.
const gridStyle = `display: grid; grid-template-columns: repeat(${numColumns}, 1fr); gap: 10px; padding: 10px;`;
// --- Start gallery grid ---
galleryHTML += `
<div class="gallery-container"
style="display:grid;
grid-template-columns:repeat(${numColumns},1fr);
gap:10px;
padding:10px;">
`;
// Build the gallery container HTML including the slider.
let galleryHTML = sliderHTML;
galleryHTML += `<div class="gallery-container" style="${gridStyle}">`;
filteredFiles.forEach((file) => {
// slice current page
const startIdx = (currentPage - 1) * itemsPerPage;
const pageFiles = filteredFiles.slice(startIdx, startIdx + itemsPerPage);
pageFiles.forEach((file, idx) => {
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
// thumbnail
let thumbnail;
if (/\.(jpg|jpeg|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]) {
thumbnail = `<img src="${window.imageCache[cacheKey]}" class="gallery-thumbnail" alt="${escapeHTML(file.name)}" style="max-width: 100%; max-height: ${getMaxImageHeight()}px; display: block; margin: 0 auto;">`;
thumbnail = `<img src="${window.imageCache[cacheKey]}"
class="gallery-thumbnail"
alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} else {
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime();
thumbnail = `<img src="${imageUrl}" onload="cacheImage(this, '${cacheKey}')" class="gallery-thumbnail" alt="${escapeHTML(file.name)}" style="max-width: 100%; max-height: ${getMaxImageHeight()}px; display: block; margin: 0 auto;">`;
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
thumbnail = `<img src="${imageUrl}"
onload="cacheImage(this,'${cacheKey}')"
class="gallery-thumbnail"
alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
}
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
@@ -441,82 +500,127 @@ export function renderGalleryView(folder, container) {
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
}
// tag badges
let tagBadgesHTML = "";
if (file.tags && file.tags.length > 0) {
if (file.tags && file.tags.length) {
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
file.tags.forEach(tag => {
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
tagBadgesHTML += `<span style="background-color:${tag.color};
color:#fff;
padding:2px 4px;
border-radius:3px;
margin-right:2px;
font-size:0.8em;">
${escapeHTML(tag.name)}
</span>`;
});
tagBadgesHTML += `</div>`;
}
// card with checkbox, preview, info, buttons
galleryHTML += `
<div class="gallery-card" style="border: 1px solid #ccc; padding: 5px; text-align: center;">
<div class="gallery-preview" style="cursor: pointer;" onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t=' + new Date().getTime(), '${file.name}')">
<div class="gallery-card"
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
<input type="checkbox"
class="file-checkbox"
id="cb-${idSafe}"
value="${escapeHTML(file.name)}"
style="position:absolute; top:5px; left:5px; z-index:10;">
<label for="cb-${idSafe}"
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview"
style="cursor:pointer;"
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')">
${thumbnail}
</div>
<div class="gallery-info" style="margin-top: 5px;">
<span class="gallery-file-name" style="display: block; white-space: normal; overflow-wrap: break-word; word-wrap: break-word;">${escapeHTML(file.name)}</span>
<div class="gallery-info" style="margin-top:5px;">
<span class="gallery-file-name"
style="display:block; white-space:normal; overflow-wrap:break-word;">
${escapeHTML(file.name)}
</span>
${tagBadgesHTML}
<div class="button-wrap" style="display: flex; justify-content: center; gap: 5px;">
<button type="button" class="btn btn-sm btn-success download-btn"
onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')"
title="${t('download')}">
<i class="material-icons">file_download</i>
<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"
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')"
title="${t('download')}">
<i class="material-icons">file_download</i>
</button>
${file.editable ? `
<button class="btn btn-sm edit-btn" onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' title="${t('Edit')}">
<i class="material-icons">edit</i>
</button>
` : ""}
<button class="btn btn-sm btn-warning rename-btn" onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i>
<button class="btn btn-sm edit-btn"
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('Edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
<button class="btn btn-sm btn-warning rename-btn"
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<button class="btn btn-sm btn-secondary share-btn" data-file="${escapeHTML(file.name)}" title="${t('share')}">
<i class="material-icons">share</i>
<button class="btn btn-sm btn-secondary share-btn"
data-file="${escapeHTML(file.name)}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
</div>
</div>
</div>`;
</div>
`;
});
galleryHTML += "</div>"; // End gallery container.
galleryHTML += `</div>`; // end gallery-container
// bottom controls
galleryHTML += buildBottomControls(itemsPerPage);
// render
fileListContent.innerHTML = galleryHTML;
// Re-apply slider constraints for the newly rendered slider.
updateSliderConstraints();
// ensure toggle button
createViewToggleButton();
// Attach share button event listeners.
document.querySelectorAll(".share-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const fileName = btn.getAttribute("data-file");
const file = fileData.find(f => f.name === fileName);
if (file) {
import('./filePreview.js').then(module => {
module.openShareModal(file, folder);
});
}
});
// attach listeners
// checkboxes
document.querySelectorAll(".file-checkbox").forEach(cb => {
cb.addEventListener("change", () => updateFileActionButtons());
});
// --- Slider Event Listener ---
// slider
const slider = document.getElementById("galleryColumnsSlider");
if (slider) {
slider.addEventListener("input", function () {
const value = this.value;
document.getElementById("galleryColumnsValue").textContent = value;
window.galleryColumns = value;
const galleryContainer = document.querySelector(".gallery-container");
if (galleryContainer) {
galleryContainer.style.gridTemplateColumns = `repeat(${value}, 1fr)`;
}
const newMaxHeight = getMaxImageHeight();
document.querySelectorAll(".gallery-thumbnail").forEach(img => {
img.style.maxHeight = newMaxHeight + "px";
});
slider.addEventListener("input", () => {
const v = +slider.value;
document.getElementById("galleryColumnsValue").textContent = v;
window.galleryColumns = v;
document.querySelector(".gallery-container")
.style.gridTemplateColumns = `repeat(${v},1fr)`;
document.querySelectorAll(".gallery-thumbnail")
.forEach(img => img.style.maxHeight = getMaxImageHeight() + "px");
});
}
// pagination
window.changePage = newPage => {
window.currentPage = newPage;
if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder);
};
// items per page
window.changeItemsPerPage = cnt => {
window.itemsPerPage = +cnt;
localStorage.setItem("itemsPerPage", cnt);
window.currentPage = 1;
if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder);
};
// update toolbar buttons
updateFileActionButtons();
}
// Responsive slider constraints based on screen size.
@@ -530,7 +634,7 @@ function updateSliderConstraints() {
// Set maximum based on screen size.
if (width < 600) { // small devices (phones)
max = 2;
max = 1;
} else if (width < 1024) { // medium devices
max = 3;
} else if (width < 1440) { // between medium and large devices
@@ -638,12 +742,22 @@ export function canEditFile(fileName) {
// Expose global functions for pagination and preview.
window.changePage = function (newPage) {
window.currentPage = newPage;
renderFileTable(window.currentFolder);
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
};
window.changeItemsPerPage = function (newCount) {
window.itemsPerPage = parseInt(newCount);
window.itemsPerPage = parseInt(newCount, 10);
localStorage.setItem('itemsPerPage', newCount);
window.currentPage = 1;
renderFileTable(window.currentFolder);
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
};
// fileListView.js (bottom)

View File

@@ -48,7 +48,7 @@ export function openShareModal(file, folder) {
document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
const expiration = document.getElementById("shareExpiration").value;
const password = document.getElementById("sharePassword").value;
fetch("api/file/createShareLink.php", {
fetch("/api/file/createShareLink.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -5,6 +5,7 @@
// filtering the file list by tag, and persisting tag data.
import { escapeHTML } from './domUtils.js';
import { t } from './i18n.js';
import { renderFileTable, renderGalleryView } from './fileListView.js';
export function openTagModal(file) {
// Create the modal element.
@@ -63,6 +64,11 @@ export function openTagModal(file) {
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
document.getElementById('tagNameInput').value = '';
updateCustomTagDropdown();
});
@@ -125,6 +131,11 @@ export function openMultiTagModal(files) {
saveFileTags(file);
});
modal.remove();
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
});
}
@@ -261,7 +272,7 @@ function removeGlobalTag(tagName) {
// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) {
fetch("api/file/saveFileTag.php", {
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
@@ -305,7 +316,7 @@ if (localStorage.getItem('globalTags')) {
// New function to load global tags from the server's persistent JSON.
export function loadGlobalTags() {
fetch("api/file/getFileTag.php", { credentials: "include" })
fetch("/api/file/getFileTag.php", { credentials: "include" })
.then(response => {
if (!response.ok) {
// If the file doesn't exist, assume there are no global tags.
@@ -438,7 +449,7 @@ export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
payload.deleteGlobal = true;
payload.tagToDelete = tagToDelete;
}
fetch("api/file/saveFileTag.php", {
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -4,6 +4,8 @@ import { loadFileList } from './fileListView.js';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
import { t } from './i18n.js';
import { openFolderShareModal } from './folderShareModal.js';
import { fetchWithCsrf } from './auth.js';
import { loadCsrfToken } from './main.js';
/* ----------------------
Helper Functions (Data/State)
@@ -102,24 +104,26 @@ export function setupBreadcrumbDelegation() {
// Click handler via delegation
function breadcrumbClickHandler(e) {
// find the nearest .breadcrumb-link
const link = e.target.closest(".breadcrumb-link");
if (!link) return;
e.stopPropagation();
e.preventDefault();
const folder = link.getAttribute("data-folder");
const folder = link.dataset.folder;
window.currentFolder = folder;
localStorage.setItem("lastOpenedFolder", folder);
// Update the container with sanitized breadcrumbs.
const container = document.getElementById("fileListTitle");
const sanitizedBreadcrumb = DOMPurify.sanitize(renderBreadcrumb(folder));
container.innerHTML = t("files_in") + " (" + sanitizedBreadcrumb + ")";
// rebuild the title safely
updateBreadcrumbTitle(folder);
expandTreePath(folder);
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (targetOption) targetOption.classList.add("selected");
document.querySelectorAll(".folder-option").forEach(el =>
el.classList.remove("selected")
);
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected");
loadFileList(folder);
}
@@ -154,7 +158,7 @@ function breadcrumbDropHandler(e) {
}
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
fetch("api/file/moveFiles.php", {
fetch("/api/file/moveFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -202,7 +206,7 @@ function checkUserFolderPermission() {
window.currentFolder = username;
return Promise.resolve(true);
}
return fetch("api/getUserPermissions.php", { credentials: "include" })
return fetch("/api/getUserPermissions.php", { credentials: "include" })
.then(response => response.json())
.then(permissionsData => {
console.log("checkUserFolderPermission: permissionsData =", permissionsData);
@@ -302,7 +306,7 @@ function folderDropHandler(event) {
}
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
fetch("api/file/moveFiles.php", {
fetch("/api/file/moveFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -333,11 +337,43 @@ function folderDropHandler(event) {
/* ----------------------
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) {
try {
// Check if the user has folder-only permission.
await checkUserFolderPermission();
// Determine effective root folder.
const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root";
@@ -351,14 +387,14 @@ export async function loadFolderTree(selectedFolder) {
} else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
// Build fetch URL.
let fetchUrl = 'api/folder/getFolderList.php';
let fetchUrl = '/api/folder/getFolderList.php';
if (window.userFolderOnly) {
fetchUrl += '?restricted=1';
}
console.log("Fetching folder list from:", fetchUrl);
// Fetch folder list from the server.
const response = await fetch(fetchUrl);
if (response.status === 401) {
@@ -375,10 +411,10 @@ export async function loadFolderTree(selectedFolder) {
} else if (Array.isArray(folderData)) {
folders = folderData;
}
// Remove any global "root" entry.
folders = folders.filter(folder => folder.toLowerCase() !== "root");
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
if (window.userFolderOnly && effectiveRoot !== "root") {
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
@@ -386,16 +422,16 @@ export async function loadFolderTree(selectedFolder) {
localStorage.setItem("lastOpenedFolder", effectiveRoot);
window.currentFolder = effectiveRoot;
}
localStorage.setItem("lastOpenedFolder", window.currentFolder);
// Render the folder tree.
const container = document.getElementById("folderTreeContainer");
if (!container) {
console.error("Folder tree container not found.");
return;
}
let html = `<div id="rootRow" class="root-row">
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
@@ -405,35 +441,35 @@ export async function loadFolderTree(selectedFolder) {
html += renderFolderTree(tree, "", "block");
}
container.innerHTML = html;
// Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
});
if (selectedFolder) {
window.currentFolder = selectedFolder;
}
localStorage.setItem("lastOpenedFolder", window.currentFolder);
const titleEl = document.getElementById("fileListTitle");
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
setupBreadcrumbDelegation();
// Initial breadcrumb update
updateBreadcrumbTitle(window.currentFolder);
loadFileList(window.currentFolder);
const folderState = loadFolderTreeState();
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
expandTreePath(window.currentFolder);
}
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
if (selectedEl) {
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
selectedEl.classList.add("selected");
}
// Folder-option click: update selection, breadcrumbs, and file list
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function (e) {
e.stopPropagation();
@@ -442,13 +478,14 @@ export async function loadFolderTree(selectedFolder) {
const selected = this.getAttribute("data-folder");
window.currentFolder = selected;
localStorage.setItem("lastOpenedFolder", selected);
const titleEl = document.getElementById("fileListTitle");
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(selected) + ")";
setupBreadcrumbDelegation();
// Safe breadcrumb update
updateBreadcrumbTitle(selected);
loadFileList(selected);
});
});
// Root toggle handler
const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) {
rootToggle.addEventListener("click", function (e) {
@@ -471,7 +508,8 @@ export async function loadFolderTree(selectedFolder) {
}
});
}
// Other folder-toggle handlers
container.querySelectorAll(".folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
@@ -494,12 +532,13 @@ export async function loadFolderTree(selectedFolder) {
}
});
});
} catch (error) {
console.error("Error loading folder tree:", error);
}
}
// For backward compatibility.
export function loadFolderList(selectedFolder) {
loadFolderTree(selectedFolder);
@@ -547,7 +586,7 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
showToast("CSRF token not loaded yet! Please try again.");
return;
}
fetch("api/folder/renameFolder.php", {
fetch("/api/folder/renameFolder.php", {
method: "POST",
credentials: "include",
headers: {
@@ -592,7 +631,7 @@ attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
const selectedFolder = window.currentFolder || "root";
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("api/folder/deleteFolder.php", {
fetch("/api/folder/deleteFolder.php", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -627,45 +666,53 @@ document.getElementById("cancelCreateFolder").addEventListener("click", function
document.getElementById("newFolderName").value = "";
});
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
document.getElementById("submitCreateFolder").addEventListener("click", function () {
document.getElementById("submitCreateFolder").addEventListener("click", async () => {
const folderInput = document.getElementById("newFolderName").value.trim();
if (!folderInput) {
showToast("Please enter a folder name.");
return;
if (!folderInput) return showToast("Please enter a folder name.");
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;
if (selectedFolder && selectedFolder !== "root") {
fullFolderName = selectedFolder + "/" + folderInput;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("api/folder/createFolder.php", {
// 2) Call with fetchWithCsrf
fetchWithCsrf("/api/folder/createFolder.php", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
folderName: folderInput,
parent: selectedFolder === "root" ? "" : selectedFolder
})
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderName: folderInput, parent })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast("Folder created successfully!");
window.currentFolder = fullFolderName;
localStorage.setItem("lastOpenedFolder", fullFolderName);
loadFolderList(fullFolderName);
} else {
showToast("Error: " + (data.error || "Could not create folder"));
.then(async res => {
if (!res.ok) {
// pull out a JSON error, or fallback to status text
let err;
try {
const j = await res.json();
err = j.error || j.message || res.statusText;
} catch {
err = res.statusText;
}
throw new Error(err);
}
return res.json();
})
.then(data => {
showToast("Folder created!");
const full = parent ? `${parent}/${folderInput}` : folderInput;
window.currentFolder = full;
localStorage.setItem("lastOpenedFolder", full);
loadFolderList(full);
})
.catch(e => {
showToast("Error creating folder: " + e.message);
})
.finally(() => {
document.getElementById("createFolderModal").style.display = "none";
document.getElementById("newFolderName").value = "";
})
.catch(error => {
console.error("Error creating folder:", error);
document.getElementById("createFolderModal").style.display = "none";
});
});

View File

@@ -64,7 +64,7 @@ export function openFolderShareModal(folder) {
return;
}
// Post to the createFolderShareLink endpoint.
fetch("api/folder/createShareFolderLink.php", {
fetch("/api/folder/createShareFolderLink.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -237,7 +237,8 @@ const translations = {
"ok": "OK",
"show": "Show",
"items_per_page": "items per page",
"columns":"Columns"
"columns":"Columns",
"api_docs": "API Docs"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -1,8 +1,10 @@
import { sendRequest } from './networkUtils.js';
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
import { loadFolderTree } from './folderManager.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 { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
@@ -12,36 +14,37 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js';
// Remove the retry logic version and just use loadCsrfToken directly:
function loadCsrfToken() {
return fetch('api/auth/token.php', { credentials: 'include' })
.then(response => {
if (!response.ok) {
throw new Error("Token fetch failed with status: " + response.status);
export function loadCsrfToken() {
return fetchWithCsrf('/api/auth/token.php', {
method: 'GET'
})
.then(res => {
if (!res.ok) {
throw new Error(`Token fetch failed with status ${res.status}`);
}
return response.json();
return res.json();
})
.then(data => {
window.csrfToken = data.csrf_token;
window.SHARE_URL = data.share_url;
let metaCSRF = document.querySelector('meta[name="csrf-token"]');
if (!metaCSRF) {
metaCSRF = document.createElement('meta');
metaCSRF.name = 'csrf-token';
document.head.appendChild(metaCSRF);
.then(({ csrf_token, share_url }) => {
// Update global and <meta>
window.csrfToken = csrf_token;
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
metaCSRF.setAttribute('content', data.csrf_token);
meta.content = csrf_token;
let metaShare = document.querySelector('meta[name="share-url"]');
if (!metaShare) {
metaShare = document.createElement('meta');
metaShare.name = 'share-url';
document.head.appendChild(metaShare);
let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
metaShare.setAttribute('content', data.share_url);
shareMeta.content = share_url;
return data;
return { csrf_token, share_url };
});
}

View File

@@ -69,7 +69,7 @@ export function setupTrashRestoreDelete() {
showToast(t("no_trash_selected"));
return;
}
fetch("api/file/restoreFiles.php", {
fetch("/api/file/restoreFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -109,7 +109,7 @@ export function setupTrashRestoreDelete() {
showToast(t("trash_empty"));
return;
}
fetch("api/file/restoreFiles.php", {
fetch("/api/file/restoreFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -151,7 +151,7 @@ export function setupTrashRestoreDelete() {
return;
}
showConfirm("Are you sure you want to permanently delete the selected trash items?", () => {
fetch("api/file/deleteTrashFiles.php", {
fetch("/api/file/deleteTrashFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -186,7 +186,7 @@ export function setupTrashRestoreDelete() {
if (deleteAllBtn) {
deleteAllBtn.addEventListener("click", () => {
showConfirm("Are you sure you want to permanently delete all trash items? This action cannot be undone.", () => {
fetch("api/file/deleteTrashFiles.php", {
fetch("/api/file/deleteTrashFiles.php", {
method: "POST",
credentials: "include",
headers: {
@@ -234,7 +234,7 @@ export function setupTrashRestoreDelete() {
* Loads trash items from the server and updates the restore modal list.
*/
export function loadTrashItems() {
fetch("api/file/getTrashItems.php", { credentials: "include" })
fetch("/api/file/getTrashItems.php", { credentials: "include" })
.then(response => response.json())
.then(trashItems => {
const listContainer = document.getElementById("restoreFilesList");
@@ -271,7 +271,7 @@ export function loadTrashItems() {
* Automatically purges (permanently deletes) trash items older than 3 days.
*/
function autoPurgeOldTrash() {
fetch("api/file/getTrashItems.php", { credentials: "include" })
fetch("/api/file/getTrashItems.php", { credentials: "include" })
.then(response => response.json())
.then(trashItems => {
const now = Date.now();
@@ -279,7 +279,7 @@ function autoPurgeOldTrash() {
const oldItems = trashItems.filter(item => (now - (item.trashedAt * 1000)) > threeDays);
if (oldItems.length > 0) {
const files = oldItems.map(item => item.trashName);
fetch("api/file/deleteTrashFiles.php", {
fetch("/api/file/deleteTrashFiles.php", {
method: "POST",
credentials: "include",
headers: {

View File

@@ -126,7 +126,7 @@ function removeChunkFolderRepeatedly(identifier, csrfToken, maxAttempts = 3, int
// Prefix with "resumable_" to match your PHP regex.
params.append('folder', 'resumable_' + identifier);
params.append('csrf_token', csrfToken);
fetch('api/upload/removeChunks.php', {
fetch('/api/upload/removeChunks.php', {
method: 'POST',
credentials: 'include',
headers: {
@@ -412,7 +412,12 @@ function initResumableUpload() {
forceChunkSize: true,
testChunks: false,
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");
@@ -496,26 +501,40 @@ function initResumableUpload() {
});
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) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
// Hide pause/resume and remove buttons for successful files.
// remove action buttons
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.style.display = "none";
}
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
const removeBtn = li.querySelector(".remove-file-btn");
if (removeBtn) {
removeBtn.style.display = "none";
}
// Schedule removal of the file entry after 5 seconds.
setTimeout(() => {
li.remove();
window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier);
updateFileInfoCount();
}, 5000);
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
loadFileList(window.currentFolder);
});
@@ -618,8 +637,25 @@ function submitFiles(allFiles) {
} catch (e) {
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];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
@@ -627,11 +663,14 @@ function submitFiles(allFiles) {
}
uploadResults[file.uploadIndex] = true;
} else {
// real failure
if (li) {
li.progressBar.innerText = "Error";
}
allSucceeded = false;
}
// ─── Only now count this chunk as finished ───────────────────
finishedCount++;
if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements);
@@ -664,7 +703,8 @@ 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.send(formData);
});

2599
public/openapi.json Normal file

File diff suppressed because it is too large Load Diff

74
public/webdav.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
// public/webdav.php
// ─── 0) Forward Basic auth into PHP_AUTH_* for every HTTP verb ─────────────
if (
empty($_SERVER['PHP_AUTH_USER'])
&& !empty($_SERVER['HTTP_AUTHORIZATION'])
&& preg_match('#Basic\s+(.*)$#i', $_SERVER['HTTP_AUTHORIZATION'], $m)
) {
[$u, $p] = explode(':', base64_decode($m[1]), 2) + ['', ''];
$_SERVER['PHP_AUTH_USER'] = $u;
$_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__ . '/../vendor/autoload.php'; // Composer & SabreDAV
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
// ─── 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';
use Sabre\DAV\Server;
use Sabre\DAV\Auth\Backend\BasicCallBack;
use Sabre\DAV\Auth\Plugin as AuthPlugin;
use Sabre\DAV\Locks\Plugin as LocksPlugin;
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
use FileRise\WebDAV\FileRiseDirectory;
// ─── 3) HTTPBasic backend ─────────────────────────────────────────────────
$authBackend = new BasicCallBack(function(string $user, string $pass) {
return \AuthModel::authenticate($user, $pass) !== false;
});
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
// ─── 4) Determine user scope ────────────────────────────────────────────────
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$isAdmin = (\AuthModel::getUserRole($user) === '1');
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
if ($isAdmin || !$folderOnly) {
// Admins (or users without folder-only restriction) see the full /uploads
$rootPath = rtrim(UPLOAD_DIR, '/\\');
} else {
// Folderonly users see only /uploads/{username}
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
if (!is_dir($rootPath)) {
mkdir($rootPath, 0755, true);
}
}
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
$server = new Server([
new FileRiseDirectory($rootPath, $user, $folderOnly),
]);
$server->addPlugin($authPlugin);
$server->addPlugin(
new LocksPlugin(
new LocksFileBackend(sys_get_temp_dir() . '/sabre-locksdb')
)
);
$server->setBaseUri('/webdav.php/');
$server->exec();

View File

@@ -35,7 +35,9 @@ class AdminController
* @OA\Property(property="disableBasicAuth", 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(
@@ -88,7 +90,9 @@ class AdminController
* @OA\Property(property="disableBasicAuth", 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(
@@ -149,7 +153,7 @@ class AdminController
exit;
}
// Prepare configuration array.
// Prepare existing settings
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
@@ -183,20 +187,38 @@ class AdminController
}
$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 = [
'header_title' => $headerTitle,
'oidc' => [
'providerUrl' => $oidcProviderUrl,
'clientId' => $oidcClientId,
'clientSecret' => $oidcClientSecret,
'redirectUri' => $oidcRedirectUri,
'header_title' => $headerTitle,
'oidc' => [
'providerUrl' => $oidcProviderUrl,
'clientId' => $oidcClientId,
'clientSecret' => $oidcClientSecret,
'redirectUri' => $oidcRedirectUri,
],
'loginOptions' => [
'loginOptions' => [
'disableFormLogin' => $disableFormLogin,
'disableBasicAuth' => $disableBasicAuth,
'disableOIDCLogin' => $disableOIDCLogin,
],
'globalOtpauthUrl' => $globalOtpauthUrl
'globalOtpauthUrl' => $globalOtpauthUrl,
'enableWebDAV' => $enableWebDAV,
'sharedMaxUploadSize' => $sharedMaxUploadSize // ← NEW
];
// Delegate to the model.
@@ -207,4 +229,4 @@ class AdminController
echo json_encode($result);
exit;
}
}
}

View File

@@ -84,7 +84,7 @@ class AuthController
if ($totpCode && isset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'])) {
$username = $_SESSION['pending_login_user'];
$secret = $_SESSION['pending_login_secret'];
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
$tfa = new TwoFactorAuth(new GoogleChartsQrCodeProvider(), 'FileRise', 6, 30, Algorithm::Sha1);
if (! $tfa->verifyCode($secret, $totpCode)) {
echo json_encode(['error' => 'Invalid TOTP code']);
@@ -203,6 +203,7 @@ class AuthController
if (! empty($user['totp_secret'])) {
$_SESSION['pending_login_user'] = $username;
$_SESSION['pending_login_secret'] = $user['totp_secret'];
$_SESSION['pending_login_remember_me'] = $rememberMe;
echo json_encode(['totp_required' => true]);
exit();
}
@@ -237,22 +238,39 @@ class AuthController
$token = bin2hex(random_bytes(32));
$expiry = time() + 30 * 24 * 60 * 60;
$all = [];
if (file_exists($tokFile)) {
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all = json_decode($dec, true) ?: [];
}
$all[$token] = [
'username' => $username,
'expiry' => $expiry,
'isAdmin' => $_SESSION['isAdmin']
'expiry' => $expiry,
'isAdmin' => $_SESSION['isAdmin']
];
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
setcookie(
session_name(),
session_id(),
$expiry,
'/',
'',
$secure,
true
);
session_regenerate_id(true);
}
echo json_encode([
@@ -323,40 +341,86 @@ class AuthController
public function checkAuth(): void
{
header('Content-Type: application/json');
// 1) Remember-me re-login
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
if ($payload) {
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
session_regenerate_id(true);
$_SESSION['csrf_token'] = $old;
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $payload['username'];
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
// regenerate CSRF if you use one
// TOTP enabled? (same logic as below)
$usersFile = USERS_DIR . USERS_FILE;
$totp = false;
if (file_exists($usersFile)) {
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(':', trim($line));
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
$totp = true;
break;
}
}
}
echo json_encode([
'authenticated' => true,
'csrf_token' => $_SESSION['csrf_token'],
'isAdmin' => $_SESSION['isAdmin'],
'totp_enabled' => $totp,
'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'],
'readOnly' => $_SESSION['readOnly'],
'disableUpload' => $_SESSION['disableUpload']
]);
exit();
}
}
$usersFile = USERS_DIR . USERS_FILE;
// setup mode?
// 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();
}
// TOTP enabled?
// 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])) {
if ($parts[0] === ($_SESSION['username'] ?? '') && !empty($parts[3])) {
$totp = true;
break;
}
}
$isAdmin = ((int)AuthModel::getUserRole($_SESSION['username']) === 1);
// 5) Final response
$resp = [
'authenticated' => true,
'isAdmin' => $isAdmin,
'totp_enabled' => $totp,
'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'] ?? false,
'readOnly' => $_SESSION['readOnly'] ?? false,
'isAdmin' => !empty($_SESSION['isAdmin']),
'totp_enabled' => $totp,
'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'] ?? false,
'readOnly' => $_SESSION['readOnly'] ?? false,
'disableUpload' => $_SESSION['disableUpload'] ?? false
];
echo json_encode($resp);
exit();
}
@@ -385,10 +449,19 @@ class AuthController
*/
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('X-CSRF-Token: ' . $_SESSION['csrf_token']);
// 3) Return JSON payload
echo json_encode([
"csrf_token" => $_SESSION['csrf_token'],
"share_url" => SHARE_URL
'csrf_token' => $_SESSION['csrf_token'],
'share_url' => SHARE_URL
]);
exit;
}

View File

@@ -450,56 +450,57 @@ class FileController {
header('Content-Type: application/json');
// --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
// --- Authentication Check ---
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Check if the user is allowed to save files (not read-only).
$username = $_SESSION['username'] ?? '';
// --- Readonly check ---
$userPermissions = loadUserPermissions($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
if ($username && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to save files."]);
exit;
}
// Get JSON input.
// --- Input parsing ---
$data = json_decode(file_get_contents("php://input"), true);
if (!$data) {
echo json_encode(["error" => "No data received"]);
exit;
}
if (!isset($data["fileName"]) || !isset($data["content"])) {
if (empty($data) || !isset($data["fileName"], $data["content"])) {
http_response_code(400);
echo json_encode(["error" => "Invalid request data", "received" => $data]);
exit;
}
$fileName = basename($data["fileName"]);
// Determine the folder. Default to "root" if not provided.
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
// Validate folder if not root.
// --- Folder validation ---
if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
}
$folder = trim($folder, "/\\ ");
// Delegate to the model.
$result = FileModel::saveFile($folder, $fileName, $data["content"]);
// --- Delegate to model, passing the uploader ---
// Make sure FileModel::saveFile signature is:
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null)
$result = FileModel::saveFile(
$folder,
$fileName,
$data["content"],
$username // ← pass the real uploader here
);
echo json_encode($result);
}

View File

@@ -401,6 +401,20 @@ class FolderController
*
* @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
{
// Retrieve GET parameters.
@@ -495,12 +509,14 @@ class FolderController
exit;
}
// Extract data for the HTML view.
$folderName = $data['folder'];
$files = $data['files'];
$currentPage = $data['currentPage'];
$totalPages = $data['totalPages'];
// Load admin config so we can pull the sharedMaxUploadSize
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
$adminConfig = AdminModel::getConfig();
$sharedMaxUploadSize = isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize'])
? (int)$adminConfig['sharedMaxUploadSize']
: null;
// For humanreadable formatting
function formatBytes($bytes)
{
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.
header("Content-Type: text/html; charset=utf-8");
?>
@@ -717,7 +739,11 @@ class FolderController
<!-- Upload Container (if uploads are allowed by the share record) -->
<?php if (isset($data['record']['allowUpload']) && $data['record']['allowUpload'] == 1): ?>
<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">
<!-- Pass the share token so the upload endpoint can verify -->
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">

View File

@@ -72,34 +72,56 @@ class UploadController {
*/
public function handleUpload(): void {
header('Content-Type: application/json');
// CSRF Protection.
//
// 1) CSRF pull from header or POST fields
//
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-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;
}
// 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);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Check user permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['disableUpload'])) {
$userPerms = loadUserPermissions($_SESSION['username']);
if (!empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(["error" => "Upload disabled for this user."]);
exit;
}
// Delegate to the model.
//
// 3) Delegate the actual file handling
//
$result = UploadModel::handleUpload($_POST, $_FILES);
// For chunked uploads, output JSON (e.g., "chunk uploaded" status).
//
// 4) Respond
//
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
@@ -109,8 +131,8 @@ class UploadController {
echo json_encode($result);
exit;
}
// Otherwise, for full upload success, set a flash message and redirect.
// fullupload redirect
$_SESSION['upload_message'] = "File uploaded successfully.";
exit;
}

View File

@@ -87,63 +87,83 @@ class UserController
public function addUser()
{
// 1) Ensure JSON output and session
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.
// Setup mode means the "setup" query parameter is passed
// and users.txt is missing, empty, or contains only whitespace.
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) {
// Allow initial admin creation without session or CSRF checks.
// 2) Determine setup mode (first-ever admin creation)
$usersFile = USERS_DIR . USERS_FILE;
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
$setupMode = false;
if (
$isSetup && (! file_exists($usersFile)
|| filesize($usersFile) === 0
|| trim(file_get_contents($usersFile)) === ''
)
) {
$setupMode = true;
} else {
$setupMode = false;
// In non-setup mode, perform CSRF token and authentication checks.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
// 3) In non-setup, enforce CSRF + auth checks
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
// 3a) Soft-fail CSRF: on mismatch, regenerate and return new 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;
}
// 3b) Must be logged in as admin
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
empty($_SESSION['authenticated'])
|| $_SESSION['authenticated'] !== true
|| empty($_SESSION['isAdmin'])
|| $_SESSION['isAdmin'] !== true
) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
}
// Get the JSON input data.
$data = json_decode(file_get_contents("php://input"), true);
$newUsername = trim($data["username"] ?? "");
$newPassword = trim($data["password"] ?? "");
// 4) Parse input
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$newUsername = trim($data['username'] ?? '');
$newPassword = trim($data['password'] ?? '');
// In setup mode, force the new user to be an admin.
// 5) Determine admin flag
if ($setupMode) {
$isAdmin = "1";
$isAdmin = '1';
} else {
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0";
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
}
// Validate that a username and password are provided.
if (!$newUsername || !$newPassword) {
// 6) Validate fields
if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]);
exit;
}
// Validate username format.
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;
}
// Delegate the business logic to the model.
// 7) Delegate to model
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
// 8) Return model result
echo json_encode($result);
exit;
}
/**
@@ -847,80 +867,125 @@ class UserController
* )
*/
public function verifyTOTP()
{
header('Content-Type: application/json');
// Set CSP headers if desired:
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
public function verifyTOTP()
{
header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
// Rate-limit
if (!isset($_SESSION['totp_failures'])) {
$_SESSION['totp_failures'] = 0;
}
if ($_SESSION['totp_failures'] >= 5) {
http_response_code(429);
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
exit;
}
// Must be authenticated OR pending login
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
exit;
}
// CSRF check
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
// Parse & validate input
$inputData = json_decode(file_get_contents("php://input"), true);
$code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
exit;
}
// TFA helper
$tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
);
// === Pending-login flow (we just came from auth and need to finish login) ===
if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
$_SESSION['totp_failures']++;
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
exit;
}
// Issue “remember me” token if requested
if ($rememberMe) {
$tokFile = USERS_DIR . 'persistent_tokens.json';
$token = bin2hex(random_bytes(32));
$expiry = time() + 30 * 24 * 60 * 60;
$all = [];
if (file_exists($tokFile)) {
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all = json_decode($dec, true) ?: [];
}
$all[$token] = [
'username' => $username,
'expiry' => $expiry,
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false
];
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
}
// === Finalize login into session exactly as finalizeLogin() would ===
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
$perms = loadUserPermissions($username);
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
// Clean up pending markers
unset(
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
$_SESSION['totp_failures']
);
// 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;
}
// Ratelimit: initialize totp_failures if not set.
if (!isset($_SESSION['totp_failures'])) {
$_SESSION['totp_failures'] = 0;
}
if ($_SESSION['totp_failures'] >= 5) {
http_response_code(429);
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
exit;
}
// Must be authenticated OR have a pending login.
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
exit;
}
// CSRF check.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
// Parse input.
$inputData = json_decode(file_get_contents("php://input"), true);
$code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
exit;
}
// Create TFA object.
$tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise',
6,
30,
\RobThree\Auth\Algorithm::Sha1
);
// Check if we are in pending login flow.
if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
$_SESSION['totp_failures']++;
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
exit;
}
// Successful pending login: finalize login.
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
// Set isAdmin based on user role.
$_SESSION['isAdmin'] = (userModel::getUserRole($username) === "1");
// Load additional permissions (e.g., folderOnly) as needed.
$_SESSION['folderOnly'] = loadUserPermissions($username);
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'], $_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
exit;
}
// Otherwise, we are in setup/verification flow.
// Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? '';
if (!$username) {
http_response_code(400);
@@ -928,7 +993,6 @@ class UserController
exit;
}
// Retrieve the user's TOTP secret from the model.
$totpSecret = userModel::getTOTPSecret($username);
if (!$totpSecret) {
http_response_code(500);
@@ -943,7 +1007,7 @@ class UserController
exit;
}
// Successful verification.
// Successful setup/verification
unset($_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
}

View File

@@ -5,6 +5,23 @@ require_once PROJECT_ROOT . '/config/config.php';
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.
@@ -24,6 +41,28 @@ class AdminModel
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.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) {
@@ -59,7 +98,8 @@ class AdminModel
*
* @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';
if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile);
@@ -72,10 +112,9 @@ class AdminModel
if (!is_array($config)) {
$config = [];
}
// Normalize login options.
// Normalize login options if missing
if (!isset($config['loginOptions'])) {
// Create loginOptions array from top-level keys if missing.
$config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
@@ -88,31 +127,43 @@ class AdminModel
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
}
// Default values for other keys
if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = "";
}
if (!isset($config['header_title']) || empty($config['header_title'])) {
$config['header_title'] = "FileRise";
}
if (!isset($config['enableWebDAV'])) {
$config['enableWebDAV'] = false;
}
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
if (!isset($config['sharedMaxUploadSize'])) {
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE));
$config['sharedMaxUploadSize'] = $defaultSms;
}
return $config;
} else {
// Return defaults.
return [
'header_title' => "FileRise",
'oidc' => [
'header_title' => "FileRise",
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => 'YOUR_CLIENT_ID',
'clientSecret' => 'YOUR_CLIENT_SECRET',
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
],
'loginOptions' => [
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => 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';
class AuthModel {
class AuthModel
{
/**
* Retrieves the user's role from the users file.
@@ -11,7 +12,8 @@ class AuthModel {
* @param string $username
* @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;
if (file_exists($usersFile)) {
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
@@ -23,7 +25,7 @@ class AuthModel {
}
return null;
}
/**
* Authenticates the user using form-based credentials.
*
@@ -31,7 +33,8 @@ class AuthModel {
* @param string $password
* @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;
if (!file_exists($usersFile)) {
return false;
@@ -51,14 +54,15 @@ class AuthModel {
}
return false;
}
/**
* Loads failed login attempts from a file.
*
* @param string $file
* @return array
*/
public static function loadFailedAttempts(string $file): array {
public static function loadFailedAttempts(string $file): array
{
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
if (is_array($data)) {
@@ -67,7 +71,7 @@ class AuthModel {
}
return [];
}
/**
* Saves failed login attempts into a file.
*
@@ -75,17 +79,19 @@ class AuthModel {
* @param array $data
* @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);
}
/**
* Retrieves a user's TOTP secret from the users file.
*
* @param string $username
* @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;
if (!file_exists($usersFile)) {
return null;
@@ -98,14 +104,15 @@ class AuthModel {
}
return null;
}
/**
* Loads the folder-only permission for a given user.
*
* @param string $username
* @return bool
*/
public static function loadFolderPermission(string $username): bool {
public static function loadFolderPermission(string $username): bool
{
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
@@ -121,4 +128,31 @@ class AuthModel {
}
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];
}
}

View File

@@ -383,88 +383,95 @@ class FileModel {
}
}
/**
* Saves file content to disk and updates folder metadata.
*
* @param string $folder The target folder where the file is to be saved (e.g. "root" or a subfolder).
* @param string $fileName The name of the file.
* @param string $content The file content.
* @return array Returns an associative array with either a "success" key or an "error" key.
*/
public static function saveFile($folder, $fileName, $content) {
// Sanitize and determine the folder name.
$folder = trim($folder) ?: 'root';
$fileName = basename(trim($fileName));
/*
* Save a files contents *and* record its metadata, including who uploaded it.
*
* @param string $folder Folder key (e.g. "root" or "invoices/2025")
* @param string $fileName Basename of the file
* @param resource|string $content File contents (stream or string)
* @param string|null $uploader Username of uploader (if null, falls back to session)
* @return array ["success"=>"…"] or ["error"=>"…"]
*/
public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array {
// Sanitize inputs
$folder = trim($folder) ?: 'root';
$fileName = basename(trim($fileName));
// Validate folder: if not "root", must match REGEX_FOLDER_NAME.
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
// Validate folder name
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
}
// Determine target directory
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$targetDir = strtolower($folder) === 'root'
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
// Security check
if (strpos(realpath($targetDir), realpath($baseDir)) !== 0) {
return ["error" => "Invalid folder path"];
}
// Ensure directory exists
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
return ["error" => "Failed to create destination folder"];
}
$filePath = $targetDir . $fileName;
// ——— STREAM TO DISK ———
if (is_resource($content)) {
$out = fopen($filePath, 'wb');
if ($out === false) {
return ["error" => "Unable to open file for writing"];
}
// Determine base upload directory.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
if (strtolower($folder) === 'root' || $folder === "") {
$targetDir = $baseDir . DIRECTORY_SEPARATOR;
} else {
$targetDir = $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
}
// (Optional security check to ensure targetDir is within baseDir.)
if (strpos(realpath($targetDir), realpath($baseDir)) !== 0) {
return ["error" => "Invalid folder path"];
}
// Create target directory if it doesn't exist.
if (!is_dir($targetDir)) {
if (!mkdir($targetDir, 0775, true)) {
return ["error" => "Failed to create destination folder"];
}
}
$filePath = $targetDir . $fileName;
// Attempt to save the file.
if (file_put_contents($filePath, $content) === false) {
stream_copy_to_stream($content, $out);
fclose($out);
} else {
if (file_put_contents($filePath, (string)$content) === false) {
return ["error" => "Error saving file"];
}
// Update metadata.
// Build metadata file path for the folder.
$metadataKey = (strtolower($folder) === "root" || $folder === "") ? "root" : $folder;
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json';
$metadataFilePath = META_DIR . $metadataFileName;
if (file_exists($metadataFilePath)) {
$metadata = json_decode(file_get_contents($metadataFilePath), true);
} else {
$metadata = [];
}
if (!is_array($metadata)) {
$metadata = [];
}
$currentTime = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
// Update metadata for the file. If already exists, update its "modified" timestamp.
if (isset($metadata[$fileName])) {
$metadata[$fileName]['modified'] = $currentTime;
$metadata[$fileName]['uploader'] = $uploader; // optional: update uploader if desired.
} else {
$metadata[$fileName] = [
"uploaded" => $currentTime,
"modified" => $currentTime,
"uploader" => $uploader
];
}
// Write updated metadata.
if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Failed to update metadata"];
}
return ["success" => "File saved successfully"];
}
// ——— UPDATE METADATA ———
$metadataKey = strtolower($folder) === "root" ? "root" : $folder;
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json';
$metadataFilePath = META_DIR . $metadataFileName;
// Load existing metadata
$metadata = [];
if (file_exists($metadataFilePath)) {
$existing = @json_decode(file_get_contents($metadataFilePath), true);
if (is_array($existing)) {
$metadata = $existing;
}
}
$currentTime = date(DATE_TIME_FORMAT);
// Use passed-in uploader, or fall back to session
if ($uploader === null) {
$uploader = $_SESSION['username'] ?? "Unknown";
}
if (isset($metadata[$fileName])) {
$metadata[$fileName]['modified'] = $currentTime;
$metadata[$fileName]['uploader'] = $uploader;
} else {
$metadata[$fileName] = [
"uploaded" => $currentTime,
"modified" => $currentTime,
"uploader" => $uploader
];
}
if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Failed to update metadata"];
}
return ["success" => "File saved successfully"];
}
/**
* Validates and retrieves information needed to download a file.
*

View File

@@ -0,0 +1,16 @@
<?php
// src/webdav/CurrentUser.php
namespace FileRise\WebDAV;
/**
* Singleton holder for the current WebDAV username.
*/
class CurrentUser {
private static string $user = 'Unknown';
public static function set(string $u): void {
self::$user = $u;
}
public static function get(): string {
return self::$user;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace FileRise\WebDAV;
// Bootstrap constants and models
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
require_once __DIR__ . '/../../src/models/FolderModel.php';
require_once __DIR__ . '/../../src/models/FileModel.php';
require_once __DIR__ . '/FileRiseFile.php';
use Sabre\DAV\ICollection;
use Sabre\DAV\INode;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Exception\Forbidden;
use FileRise\WebDAV\FileRiseFile;
use FolderModel;
use FileModel;
class FileRiseDirectory implements ICollection, INode {
private string $path;
private string $user;
private bool $folderOnly;
/**
* @param string $path Absolute filesystem path (no trailing slash)
* @param string $user Authenticated username
* @param bool $folderOnly If true, nonadmins only see $path/{user}
*/
public function __construct(string $path, string $user, bool $folderOnly) {
$this->path = rtrim($path, '/\\');
$this->user = $user;
$this->folderOnly = $folderOnly;
}
// ── INode ───────────────────────────────────────────
public function getName(): string {
return basename($this->path);
}
public function getLastModified(): int {
return filemtime($this->path);
}
public function delete(): void {
throw new Forbidden('Cannot delete this node');
}
public function setName($name): void {
throw new Forbidden('Renaming not supported');
}
// ── ICollection ────────────────────────────────────
public function getChildren(): array {
$nodes = [];
foreach (new \DirectoryIterator($this->path) as $item) {
if ($item->isDot()) continue;
$full = $item->getPathname();
if ($item->isDir()) {
$nodes[] = new self($full, $this->user, $this->folderOnly);
} else {
$nodes[] = new FileRiseFile($full, $this->user);
}
}
// Apply folderonly at the top level
if (
$this->folderOnly
&& realpath($this->path) === realpath(rtrim(UPLOAD_DIR,'/\\'))
) {
$nodes = array_filter($nodes, fn(INode $n)=> $n->getName() === $this->user);
}
return array_values($nodes);
}
public function childExists($name): bool {
return file_exists($this->path . DIRECTORY_SEPARATOR . $name);
}
public function getChild($name): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
if (!file_exists($full)) throw new NotFound("Not found: $name");
return is_dir($full)
? new self($full, $this->user, $this->folderOnly)
: new FileRiseFile($full, $this->user);
}
public function createFile($name, $data = null): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
// Compute folderkey relative to UPLOAD_DIR
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
$parts = explode('/', str_replace('\\','/',$rel));
$filename = array_pop($parts);
$folder = empty($parts) ? 'root' : implode('/', $parts);
FileModel::saveFile($folder, $filename, $content, $this->user);
return new FileRiseFile($full, $this->user);
}
public function createDirectory($name): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
$parent = dirname(str_replace('\\','/',$rel));
if ($parent === '.' || $parent === '/') $parent = '';
FolderModel::createFolder($name, $parent, $this->user);
return new self($full, $this->user, $this->folderOnly);
}
}

115
src/webdav/FileRiseFile.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
// src/webdav/FileRiseFile.php
namespace FileRise\WebDAV;
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/models/FileModel.php';
use Sabre\DAV\IFile;
use Sabre\DAV\INode;
use Sabre\DAV\Exception\Forbidden;
use FileModel;
class FileRiseFile implements IFile, INode {
private string $path;
public function __construct(string $path) {
$this->path = $path;
}
// ── INode ───────────────────────────────────────────
public function getName(): string {
return basename($this->path);
}
public function getLastModified(): int {
return filemtime($this->path);
}
public function delete(): void {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
$rel = substr($this->path, strlen($base));
$parts = explode(DIRECTORY_SEPARATOR, $rel);
$file = array_pop($parts);
$folder = empty($parts) ? 'root' : $parts[0];
FileModel::deleteFiles($folder, [$file]);
}
public function setName($newName): void {
throw new Forbidden('Renaming files not supported');
}
// ── IFile ───────────────────────────────────────────
public function get() {
return fopen($this->path, 'rb');
}
public function put($data): ?string {
// 1) Save incoming data
file_put_contents(
$this->path,
is_resource($data) ? stream_get_contents($data) : (string)$data
);
// 2) Update metadata with CurrentUser
$this->updateMetadata();
// 3) Flush to client fast
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
return null; // no ETag
}
public function getSize(): int {
return filesize($this->path);
}
public function getETag(): string {
return '"' . md5($this->getLastModified() . $this->getSize()) . '"';
}
public function getContentType(): ?string {
return mime_content_type($this->path) ?: null;
}
// ── Metadata helper ───────────────────────────────────
private function updateMetadata(): void {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
$rel = substr($this->path, strlen($base));
$parts = explode(DIRECTORY_SEPARATOR, $rel);
$fileName = array_pop($parts);
$folder = empty($parts) ? 'root' : $parts[0];
$metaFile = META_DIR
. ($folder === 'root'
? 'root_metadata.json'
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
$metadata = [];
if (file_exists($metaFile)) {
$decoded = json_decode(file_get_contents($metaFile), true);
if (is_array($decoded)) {
$metadata = $decoded;
}
}
$now = date(DATE_TIME_FORMAT);
$uploaded = $metadata[$fileName]['uploaded'] ?? $now;
$uploader = CurrentUser::get();
$metadata[$fileName] = [
'uploaded' => $uploaded,
'modified' => $now,
'uploader' => $uploader,
];
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
}
}

194
start.sh
View File

@@ -1,162 +1,112 @@
#!/bin/bash
set -euo pipefail
echo "🚀 Running start.sh..."
# Warn if default persistent tokens key is in use
if [ "$PERSISTENT_TOKENS_KEY" = "default_please_change_this_key" ]; then
echo "⚠️ WARNING: Using default persistent tokens key. Please override PERSISTENT_TOKENS_KEY for production."
# 1) Tokenkey warning
if [ "${PERSISTENT_TOKENS_KEY}" = "default_please_change_this_key" ]; then
echo "⚠️ WARNING: Using default persistent tokens key—override for production."
fi
# Update config.php based on environment variables
# 2) Update config.php based on environment variables
CONFIG_FILE="/var/www/config/config.php"
if [ -f "$CONFIG_FILE" ]; then
echo "🔄 Updating config.php based on environment variables..."
if [ -n "$TIMEZONE" ]; then
echo " Setting TIMEZONE to $TIMEZONE"
sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '$TIMEZONE');|" "$CONFIG_FILE"
fi
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"
if [ -f "${CONFIG_FILE}" ]; then
echo "🔄 Updating config.php from env vars..."
[ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
[ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
fi
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
[ -n "${SHARE_URL:-}" ] && sed -i "s|define('SHARE_URL',[[:space:]]*'[^']*');|define('SHARE_URL', '${SHARE_URL}');|" "${CONFIG_FILE}"
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
# Update PHP upload limits at runtime if TOTAL_UPLOAD_SIZE is set.
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then
echo "🔄 Updating PHP upload limits with TOTAL_UPLOAD_SIZE=$TOTAL_UPLOAD_SIZE"
echo "upload_max_filesize = $TOTAL_UPLOAD_SIZE" > /etc/php/8.3/apache2/conf.d/99-custom.ini
echo "post_max_size = $TOTAL_UPLOAD_SIZE" >> /etc/php/8.3/apache2/conf.d/99-custom.ini
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
cat > /etc/php/8.3/apache2/conf.d/99-custom.ini <<EOF
upload_max_filesize = ${TOTAL_UPLOAD_SIZE}
post_max_size = ${TOTAL_UPLOAD_SIZE}
EOF
fi
# Update Apache LimitRequestBody based on TOTAL_UPLOAD_SIZE if set.
if [ -n "$TOTAL_UPLOAD_SIZE" ]; then
size_str=$(echo "$TOTAL_UPLOAD_SIZE" | tr '[:upper:]' '[:lower:]')
factor=1
# 4) Adjust Apache LimitRequestBody
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
# convert to bytes
size_str=$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')
case "${size_str: -1}" in
g)
factor=$((1024*1024*1024))
size_num=${size_str%g}
;;
m)
factor=$((1024*1024))
size_num=${size_str%m}
;;
k)
factor=1024
size_num=${size_str%k}
;;
*)
size_num=$size_str
;;
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
m) factor=$((1024*1024)); num=${size_str%m} ;;
k) factor=1024; num=${size_str%k} ;;
*) factor=1; num=${size_str} ;;
esac
LIMIT_REQUEST_BODY=$((size_num * factor))
echo "🔄 Setting Apache LimitRequestBody to $LIMIT_REQUEST_BODY bytes (from TOTAL_UPLOAD_SIZE=$TOTAL_UPLOAD_SIZE)"
cat <<EOF > /etc/apache2/conf-enabled/limit_request_body.conf
LIMIT_REQUEST_BODY=$(( num * factor ))
echo "🔄 Setting Apache LimitRequestBody to ${LIMIT_REQUEST_BODY} bytes"
cat > /etc/apache2/conf-enabled/limit_request_body.conf <<EOF
<Directory "/var/www/public">
LimitRequestBody $LIMIT_REQUEST_BODY
LimitRequestBody ${LIMIT_REQUEST_BODY}
</Directory>
EOF
fi
# Set Apache Timeout (default is 300 seconds)
echo "🔄 Setting Apache Timeout to 600 seconds"
cat <<EOF > /etc/apache2/conf-enabled/timeout.conf
# 5) Configure Apache timeout (600s)
cat > /etc/apache2/conf-enabled/timeout.conf <<EOF
Timeout 600
EOF
echo "🔥 Final Apache Timeout configuration:"
cat /etc/apache2/conf-enabled/timeout.conf
# Update Apache ports if environment variables are provided
if [ -n "$HTTP_PORT" ]; then
echo "🔄 Setting Apache HTTP port to $HTTP_PORT"
sed -i "s/^Listen 80$/Listen $HTTP_PORT/" /etc/apache2/ports.conf
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:$HTTP_PORT>/" /etc/apache2/sites-available/000-default.conf
# 6) Override ports if provided
if [ -n "${HTTP_PORT:-}" ]; then
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf
fi
if [ -n "${HTTPS_PORT:-}" ]; then
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf
fi
if [ -n "$HTTPS_PORT" ]; then
echo "🔄 Setting Apache HTTPS port to $HTTPS_PORT"
sed -i "s/^Listen 443$/Listen $HTTPS_PORT/" /etc/apache2/ports.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
# 7) Set ServerName
if [ -n "${SERVER_NAME:-}" ]; then
echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
else
echo "🔄 Setting Apache ServerName to default: FileRise"
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
fi
echo "Final /etc/apache2/ports.conf content:"
cat /etc/apache2/ports.conf
# 8) Prepare dynamic data directories with least privilege
for d in uploads users metadata; do
tgt="/var/www/${d}"
mkdir -p "${tgt}"
chown www-data:www-data "${tgt}"
chmod 775 "${tgt}"
done
echo "📁 Web app is served from /var/www/public."
# 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)
# 9) Initialize persistent files if absent
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
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
else
echo " users.txt already exists; preserving persistent data."
fi
# Create createdTags.json only if it doesn't already exist (preserving persistent data)
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
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
else
echo " createdTags.json already exists; preserving persistent data."
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..."
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>