Compare commits

...

16 Commits

31 changed files with 1958 additions and 631 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
View File

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

View File

@@ -5,6 +5,9 @@ on:
paths:
- 'CHANGELOG.md'
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,95 @@
# Changelog
## Changes 4/19/2025
## 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
## 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)`.

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,73 @@ 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>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/public
Alias /uploads/ /var/www/uploads/
<Directory "/var/www/uploads/">
Options -Indexes
AllowOverride None
Require all granted
</Directory>
<Directory "/var/www/public">
AllowOverride All
Require all granted
DirectoryIndex index.php index.html
DirectoryIndex index.html
</Directory>
ErrorLog /var/log/apache2/error.log
CustomLog /var/log/apache2/access.log combined
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

@@ -20,6 +20,8 @@ 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.
@@ -34,7 +36,7 @@ Upload, organize, and share files through a sleek web interface. **FileRise** is
- 🗑️ **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.)
@@ -115,7 +117,7 @@ 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).
- **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

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

@@ -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 = {}) {
options = { credentials: 'include', headers: {}, ...options };
options.headers['X-CSRF-Token'] = window.csrfToken;
// 1) First attempt using the original fetch
let res = await originalFetch(url, options);
// 2) Softfailure JSON check (200 + {csrf_expired})
if (res.ok && res.headers.get('content-type')?.includes('application/json')) {
const clone = res.clone();
const data = await clone.json();
if (data.csrf_expired) {
const newToken = data.csrf_token;
window.csrfToken = newToken;
document.querySelector('meta[name="csrf-token"]').content = newToken;
options.headers['X-CSRF-Token'] = newToken;
return originalFetch(url, options);
}
}
// 3) HTTP 403 fallback
if (res.status === 403) {
let newToken = res.headers.get('X-CSRF-Token');
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) {
window.csrfToken = newToken;
document.querySelector('meta[name="csrf-token"]').content = newToken;
options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options);
}
}
return res;
}
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
function openTOTPLoginModal() {
originalOpenTOTPLoginModal();
@@ -228,6 +277,7 @@ function checkAuthentication(showLoginToast = true) {
}
window.setupMode = false;
if (data.authenticated) {
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
localStorage.setItem("folderOnly", data.folderOnly);
localStorage.setItem("readOnly", data.readOnly);
localStorage.setItem("disableUpload", data.disableUpload);
@@ -235,6 +285,10 @@ function checkAuthentication(showLoginToast = true) {
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 {
@@ -276,11 +330,11 @@ async function submitLogin(data) {
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");
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
}
} catch {}
} catch { }
return window.location.reload();
}
@@ -405,10 +459,10 @@ function initAuth() {
}
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())
@@ -438,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())
@@ -477,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,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js';
const version = "v1.2.1"; // Update this version string as needed
const version = "v1.2.4"; // Update this version string as needed
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null;
@@ -597,6 +597,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 = `
@@ -611,6 +612,7 @@ export function openAdminPanel() {
max-height: 90vh;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
`;
let adminModal = document.getElementById("adminPanelModal");
if (!adminModal) {
@@ -663,6 +665,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">
@@ -698,33 +722,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();
});
document.getElementById("saveAdminSettings").addEventListener("click", () => {
// 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;
@@ -738,8 +763,8 @@ export function openAdminPanel() {
}
return;
}
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
const newHeaderTitle = document.getElementById("headerTitle").value.trim();
const newOIDCConfig = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(),
@@ -749,13 +774,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", {
header_title: newHeaderTitle,
oidc: newOIDCConfig,
disableFormLogin,
disableBasicAuth,
disableOIDCLogin,
enableWebDAV,
sharedMaxUploadSize,
globalOtpauthUrl
}, { "X-CSRF-Token": window.csrfToken })
.then(response => {
@@ -764,26 +794,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;
@@ -793,13 +829,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;
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// Capture initial state after the modal loads.
captureInitialAdminConfig();
} else {
// Update existing modal and show
adminModal.style.backgroundColor = overlayBackground;
const modalContent = adminModal.querySelector(".modal-content");
if (modalContent) {
@@ -815,6 +855,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();
}
@@ -837,6 +879,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 {

View File

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

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)
@@ -337,7 +339,7 @@ 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 +353,14 @@ export async function loadFolderTree(selectedFolder) {
} else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
// Build fetch URL.
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 +377,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 +388,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 +407,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();
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");
}
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function (e) {
e.stopPropagation();
@@ -448,7 +450,7 @@ export async function loadFolderTree(selectedFolder) {
loadFileList(selected);
});
});
const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) {
rootToggle.addEventListener("click", function (e) {
@@ -471,7 +473,7 @@ export async function loadFolderTree(selectedFolder) {
}
});
}
container.querySelectorAll(".folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
@@ -494,7 +496,7 @@ export async function loadFolderTree(selectedFolder) {
}
});
});
} catch (error) {
console.error("Error loading folder tree:", error);
}
@@ -627,45 +629,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

@@ -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';
@@ -13,35 +15,53 @@ 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' })
/**
* Fetches the current CSRF token (and share URL), updates window globals
* and <meta> tags, and returns the data.
*
* @returns {Promise<{csrf_token: string, share_url: string}>}
*/
export function loadCsrfToken() {
return fetch('/api/auth/token.php', {
method: 'GET',
credentials: 'include'
})
.then(response => {
if (!response.ok) {
throw new Error("Token fetch failed with status: " + response.status);
throw new Error(`Token fetch failed with status: ${response.status}`);
}
return response.json();
// Prefer header if set, otherwise fall back to body
const headerToken = response.headers.get('X-CSRF-Token');
return response.json()
.then(body => ({
csrf_token: headerToken || body.csrf_token,
share_url: body.share_url
}));
})
.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);
}
metaCSRF.setAttribute('content', data.csrf_token);
.then(({ csrf_token, share_url }) => {
// Update globals
window.csrfToken = csrf_token;
window.SHARE_URL = share_url;
let metaShare = document.querySelector('meta[name="share-url"]');
if (!metaShare) {
metaShare = document.createElement('meta');
metaShare.name = 'share-url';
document.head.appendChild(metaShare);
// Sync <meta name="csrf-token">
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
metaShare.setAttribute('content', data.share_url);
meta.content = csrf_token;
return data;
// Sync <meta name="share-url">
let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
shareMeta.content = share_url;
return { csrf_token, share_url };
});
}

View File

@@ -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);
@@ -665,6 +704,7 @@ function submitFiles(allFiles) {
});
xhr.open("POST", "/api/upload/upload.php", true);
xhr.withCredentials = true;
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
xhr.send(formData);
});

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

@@ -238,28 +238,28 @@ 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']
];
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(),
@@ -269,7 +269,7 @@ class AuthController
$secure,
true
);
session_regenerate_id(true);
}
@@ -341,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();
}
@@ -403,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,147 +867,151 @@ class UserController
* )
*/
public function verifyTOTP()
{
header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
// Ratelimit
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 and 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
);
// Pendinglogin flow (first password step passed)
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' => $_SESSION['isAdmin']
];
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// Persistent cookie
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
// Reissue PHP session cookie
setcookie(
session_name(),
session_id(),
$expiry,
'/',
'',
$secure,
true
);
}
// Finalize login
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = (userModel::getUserRole($username) === "1");
$_SESSION['folderOnly'] = loadUserPermissions($username);
// Clean up
unset(
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
$_SESSION['totp_failures']
);
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
exit;
}
// Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? '';
if (!$username) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
exit;
}
$totpSecret = userModel::getTOTPSecret($username);
if (!$totpSecret) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
exit;
}
if (!$tfa->verifyCode($totpSecret, $code)) {
$_SESSION['totp_failures']++;
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
exit;
}
// Successful setup/verification
unset($_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
}
public function verifyTOTP()
{
header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
// Ratelimit
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 and 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
);
// Pendinglogin flow (first password step passed)
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) ?: [];
}
$isAdmin = ((int)userModel::getUserRole($username) === 1);
$all[$token] = [
'username' => $username,
'expiry' => $expiry,
'isAdmin' => $isAdmin
];
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// Persistent cookie
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
// Reissue PHP session cookie
setcookie(
session_name(),
session_id(),
$expiry,
'/',
'',
$secure,
true
);
}
// Finalize login
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = $isAdmin;
$_SESSION['folderOnly'] = loadUserPermissions($username);
// Clean up
unset(
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
$_SESSION['totp_failures']
);
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
exit;
}
// Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? '';
if (!$username) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
exit;
}
$totpSecret = userModel::getTOTPSecret($username);
if (!$totpSecret) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
exit;
}
if (!$tfa->verifyCode($totpSecret, $code)) {
$_SESSION['totp_failures']++;
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
exit;
}
// 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