Compare commits

..

9 Commits

Author SHA1 Message Date
Ryan
06b3f28df0 New fetchWithCsrf with fallback for session change. start.sh session directory added. 2025-04-23 09:53:21 -04:00
Ryan
89f124250c Fixed totp isAdmin when session is missing but remember_me_token cookie present 2025-04-23 02:30:43 -04:00
Ryan
66f13fd6a7 dockerignore cleanup 2025-04-23 01:50:24 -04:00
Ryan
a81d9cb940 Enhance remember me 2025-04-23 01:47:27 -04:00
Ryan
13b8871200 docker: remove symlink add alias for uploads folder 2025-04-22 22:28:06 -04:00
Ryan
2792c05c1c docker: consolidate config & security improvements 2025-04-22 21:34:21 -04:00
Ryan
6ccfc88acb Composer & WebDAV readme changes 2025-04-22 19:27:53 -04:00
Ryan
7f1d59b33a add acknowledgements to README and LICENSE 2025-04-22 19:06:33 -04:00
Ryan
e4e8b108d2 Add permissions to workflow 2025-04-22 18:11:42 -04:00
19 changed files with 753 additions and 501 deletions

14
.dockerignore Normal file
View File

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

2
.gitattributes vendored
View File

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

View File

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

View File

@@ -1,5 +1,35 @@
# Changelog # Changelog
## 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 ## Changes 4/22/2025 v1.2.3
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user` - Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
@@ -21,6 +51,16 @@
- `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize` - `getConfig` and `updateConfig` endpoints now include `enableWebDAV` and `sharedMaxUploadSize`
- Updated `AdminModel` & `AdminController` to persist and validate new settings - Updated `AdminModel` & `AdminController` to persist and validate new settings
- Enhanced `shareFolder()` view to pull from admin config and format the maxuploadsize label - 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
--- ---

View File

@@ -6,12 +6,9 @@
FROM ubuntu:24.04 AS appsource FROM ubuntu:24.04 AS appsource
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \ apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/* # clean up apt cache
# prepare the folder and remove Apaches default index
RUN mkdir -p /var/www && rm -f /var/www/html/index.html RUN mkdir -p /var/www && rm -f /var/www/html/index.html
# **Copy the FileRise source** (where your composer.json lives)
COPY . /var/www COPY . /var/www
############################# #############################
@@ -19,94 +16,73 @@ COPY . /var/www
############################# #############################
FROM composer:2 AS composer FROM composer:2 AS composer
WORKDIR /app WORKDIR /app
# **Copy composer files from the source** and install
COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./ COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./
RUN composer install --no-dev --optimize-autoloader RUN composer install --no-dev --optimize-autoloader # production-ready autoloader
############################# #############################
# Final Stage runtime image # Final Stage runtime image
############################# #############################
FROM ubuntu:24.04 FROM ubuntu:24.04
LABEL by=error311 LABEL by=error311
# Set basic environment variables (these can be overridden via the Unraid template)
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \ HOME=/root \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 TERM=xterm \
LANG=en_US.UTF-8 \ UPLOAD_MAX_FILESIZE=5G POST_MAX_SIZE=5G TOTAL_UPLOAD_SIZE=5G \
LANGUAGE=en_US.UTF-8 \
TERM=xterm \
UPLOAD_MAX_FILESIZE=5G \
POST_MAX_SIZE=5G \
TOTAL_UPLOAD_SIZE=5G \
PERSISTENT_TOKENS_KEY=default_please_change_this_key \ PERSISTENT_TOKENS_KEY=default_please_change_this_key \
PUID=99 \ PUID=99 PGID=100
PGID=100
# Install Apache, PHP, and required extensions # Install Apache, PHP, and required extensions
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y && \ apt-get upgrade -y && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
apache2 \ apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
php \ ca-certificates curl git openssl && \
php-json \ apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
php-curl \
php-zip \
php-mbstring \
php-gd \
php-xml \
ca-certificates \
curl \
git \
openssl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Remap www-data to the PUID/PGID provided # Remap www-data to the PUID/PGID provided for safe bind mounts
RUN set -eux; \ RUN set -eux; \
# only change the UID if its not already correct if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u "${PUID}" www-data; fi; \
if [ "$(id -u www-data)" != "${PUID}" ]; then \ if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g "${PGID}" www-data 2>/dev/null || true; fi; \
usermod -u "${PUID}" www-data; \
fi; \
# attempt to change the GID, but ignore “already exists” errors
if [ "$(id -g www-data)" != "${PGID}" ]; then \
groupmod -g "${PGID}" www-data 2>/dev/null || true; \
fi; \
# finally set www-datas primary group to PGID (will succeed if the group exists)
usermod -g "${PGID}" www-data usermod -g "${PGID}" www-data
# Copy application tuning and code # Copy config, code, and vendor
COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
COPY --from=appsource /var/www /var/www COPY --from=appsource /var/www /var/www
COPY --from=composer /app/vendor /var/www/vendor COPY --from=composer /app/vendor /var/www/vendor
# Ensure the webroot is owned by the remapped www-data user # Secure permissions: code read-only, only data dirs writable
RUN chown -R www-data:www-data /var/www && chmod -R 775 /var/www RUN chown -R root:www-data /var/www && \
find /var/www -type d -exec chmod 755 {} \; && \
find /var/www -type f -exec chmod 644 {} \; && \
mkdir -p /var/www/public/uploads /var/www/users /var/www/metadata && \
chown -R www-data:www-data /var/www/public/uploads /var/www/users /var/www/metadata && \
chmod -R 775 /var/www/public/uploads /var/www/users /var/www/metadata # writable upload areas
# Create a symlink for uploads folder in public directory. # Apache site configuration
RUN cd /var/www/public && ln -s ../uploads uploads
# Configure Apache
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80> <VirtualHost *:80>
ServerAdmin webmaster@localhost ServerAdmin webmaster@localhost
DocumentRoot /var/www/public 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"> <Directory "/var/www/public">
AllowOverride All AllowOverride All
Require all granted Require all granted
DirectoryIndex index.php index.html DirectoryIndex index.html
</Directory> </Directory>
ErrorLog /var/log/apache2/error.log ErrorLog /var/www/metadata/log/error.log
CustomLog /var/log/apache2/access.log combined CustomLog /var/www/metadata/log/access.log combined
</VirtualHost> </VirtualHost>
EOF EOF
# Enable the rewrite and headers modules # Enable required modules
RUN a2enmod rewrite headers RUN a2enmod rewrite headers
# Expose ports and set up the startup script
EXPOSE 80 443 EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh

View File

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

View File

@@ -117,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). 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): - **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist):
@@ -149,7 +149,7 @@ Now navigate to the FileRise URL in your browser. On first load, youll be pro
## Quickstart: Mount via WebDAV ## Quickstart: Mount via WebDAV
Once FileRise is running, you can mount it like any other network drive: Once FileRise is running, you must enable WebDAV in admin panel to access it.
```bash ```bash
# Linux (GVFS/GIO) # Linux (GVFS/GIO)
@@ -245,6 +245,12 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
--- ---
## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
---
## License ## License
This project is open-source under the MIT License. That means youre free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back! This project is open-source under the MIT License. That means youre free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!

View File

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

View File

@@ -44,6 +44,55 @@ function showToast(msgKey) {
} }
window.showToast = showToast; window.showToast = showToast;
const originalFetch = window.fetch;
/*
* @param {string} url
* @param {object} options
* @returns {Promise<Response>}
*/
export async function fetchWithCsrf(url, options = {}) {
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 // wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
function openTOTPLoginModal() { function openTOTPLoginModal() {
originalOpenTOTPLoginModal(); originalOpenTOTPLoginModal();
@@ -228,6 +277,7 @@ function checkAuthentication(showLoginToast = true) {
} }
window.setupMode = false; window.setupMode = false;
if (data.authenticated) { if (data.authenticated) {
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
localStorage.setItem("folderOnly", data.folderOnly); localStorage.setItem("folderOnly", data.folderOnly);
localStorage.setItem("readOnly", data.readOnly); localStorage.setItem("readOnly", data.readOnly);
localStorage.setItem("disableUpload", data.disableUpload); localStorage.setItem("disableUpload", data.disableUpload);
@@ -235,6 +285,10 @@ function checkAuthentication(showLoginToast = true) {
if (typeof data.totp_enabled !== "undefined") { if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
} }
if (data.csrf_token) {
window.csrfToken = data.csrf_token;
document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
}
updateAuthenticatedUI(data); updateAuthenticatedUI(data);
return data; return data;
} else { } else {
@@ -278,9 +332,9 @@ async function submitLogin(data) {
if (perm && typeof perm === "object") { if (perm && typeof perm === "object") {
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false"); localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false"); localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
localStorage.setItem("disableUpload",perm.disableUpload? "true" : "false"); localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
} }
} catch {} } catch { }
return window.location.reload(); return window.location.reload();
} }
@@ -405,10 +459,10 @@ function initAuth() {
} }
let url = "/api/addUser.php"; let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1"; if (window.setupMode) url += "?setup=1";
fetch(url, { fetchWithCsrf(url, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin }) body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -438,10 +492,10 @@ function initAuth() {
} }
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?"); const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
if (!confirmed) return; if (!confirmed) return;
fetch("/api/removeUser.php", { fetchWithCsrf("/api/removeUser.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: usernameToRemove }) body: JSON.stringify({ username: usernameToRemove })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -477,10 +531,10 @@ function initAuth() {
return; return;
} }
const data = { oldPassword, newPassword, confirmPassword }; const data = { oldPassword, newPassword, confirmPassword };
fetch("/api/changePassword.php", { fetchWithCsrf("/api/changePassword.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
.then(response => response.json()) .then(response => response.json())

View File

@@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js'; import { loadAdminConfigFunc } from './auth.js';
const version = "v1.2.3"; // 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>`; const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null; let lastLoginData = null;

View File

@@ -4,6 +4,8 @@ import { loadFileList } from './fileListView.js';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js'; import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { openFolderShareModal } from './folderShareModal.js'; import { openFolderShareModal } from './folderShareModal.js';
import { fetchWithCsrf } from './auth.js';
import { loadCsrfToken } from './main.js';
/* ---------------------- /* ----------------------
Helper Functions (Data/State) Helper Functions (Data/State)
@@ -627,45 +629,53 @@ document.getElementById("cancelCreateFolder").addEventListener("click", function
document.getElementById("newFolderName").value = ""; document.getElementById("newFolderName").value = "";
}); });
attachEnterKeyListener("createFolderModal", "submitCreateFolder"); attachEnterKeyListener("createFolderModal", "submitCreateFolder");
document.getElementById("submitCreateFolder").addEventListener("click", function () { document.getElementById("submitCreateFolder").addEventListener("click", async () => {
const folderInput = document.getElementById("newFolderName").value.trim(); const folderInput = document.getElementById("newFolderName").value.trim();
if (!folderInput) { if (!folderInput) return showToast("Please enter a folder name.");
showToast("Please enter a folder name.");
return; const selectedFolder = window.currentFolder || "root";
const parent = selectedFolder === "root" ? "" : selectedFolder;
// 1) Guarantee fresh CSRF
try {
await loadCsrfToken();
} catch {
return showToast("Could not refresh CSRF token. Please reload.");
} }
let selectedFolder = window.currentFolder || "root";
let fullFolderName = folderInput; // 2) Call with fetchWithCsrf
if (selectedFolder && selectedFolder !== "root") { fetchWithCsrf("/api/folder/createFolder.php", {
fullFolderName = selectedFolder + "/" + folderInput;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("/api/folder/createFolder.php", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ folderName: folderInput, parent })
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
folderName: folderInput,
parent: selectedFolder === "root" ? "" : selectedFolder
}) })
}) .then(async res => {
.then(response => response.json()) if (!res.ok) {
.then(data => { // pull out a JSON error, or fallback to status text
if (data.success) { let err;
showToast("Folder created successfully!"); try {
window.currentFolder = fullFolderName; const j = await res.json();
localStorage.setItem("lastOpenedFolder", fullFolderName); err = j.error || j.message || res.statusText;
loadFolderList(fullFolderName); } catch {
} else { err = res.statusText;
showToast("Error: " + (data.error || "Could not create folder"));
} }
throw new Error(err);
}
return res.json();
})
.then(data => {
showToast("Folder created!");
const full = parent ? `${parent}/${folderInput}` : folderInput;
window.currentFolder = full;
localStorage.setItem("lastOpenedFolder", full);
loadFolderList(full);
})
.catch(e => {
showToast("Error creating folder: " + e.message);
})
.finally(() => {
document.getElementById("createFolderModal").style.display = "none"; document.getElementById("createFolderModal").style.display = "none";
document.getElementById("newFolderName").value = ""; document.getElementById("newFolderName").value = "";
})
.catch(error => {
console.error("Error creating folder:", error);
document.getElementById("createFolderModal").style.display = "none";
}); });
}); });

View File

@@ -1,8 +1,10 @@
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js'; import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
import { loadFolderTree } from './folderManager.js';
import { initUpload } from './upload.js'; import { initUpload } from './upload.js';
import { initAuth, checkAuthentication, loadAdminConfigFunc } from './auth.js'; import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
const _originalFetch = window.fetch;
window.fetch = fetchWithCsrf;
import { loadFolderTree } from './folderManager.js';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js'; import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js'; import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js'; import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
@@ -13,35 +15,53 @@ import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
// Remove the retry logic version and just use loadCsrfToken directly: // 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 => { .then(response => {
if (!response.ok) { 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 => { .then(({ csrf_token, share_url }) => {
window.csrfToken = data.csrf_token; // Update globals
window.SHARE_URL = data.share_url; window.csrfToken = csrf_token;
window.SHARE_URL = share_url;
let metaCSRF = document.querySelector('meta[name="csrf-token"]'); // Sync <meta name="csrf-token">
if (!metaCSRF) { let meta = document.querySelector('meta[name="csrf-token"]');
metaCSRF = document.createElement('meta'); if (!meta) {
metaCSRF.name = 'csrf-token'; meta = document.createElement('meta');
document.head.appendChild(metaCSRF); meta.name = 'csrf-token';
document.head.appendChild(meta);
} }
metaCSRF.setAttribute('content', data.csrf_token); meta.content = csrf_token;
let metaShare = document.querySelector('meta[name="share-url"]'); // Sync <meta name="share-url">
if (!metaShare) { let shareMeta = document.querySelector('meta[name="share-url"]');
metaShare = document.createElement('meta'); if (!shareMeta) {
metaShare.name = 'share-url'; shareMeta = document.createElement('meta');
document.head.appendChild(metaShare); shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
} }
metaShare.setAttribute('content', data.share_url); shareMeta.content = share_url;
return data; return { csrf_token, share_url };
}); });
} }

View File

@@ -412,7 +412,12 @@ function initResumableUpload() {
forceChunkSize: true, forceChunkSize: true,
testChunks: false, testChunks: false,
throttleProgressCallbacks: 1, throttleProgressCallbacks: 1,
headers: { "X-CSRF-Token": window.csrfToken } withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: {
folder: window.currentFolder || "root",
upload_token: window.csrfToken // still as a fallback
}
}); });
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
@@ -496,26 +501,40 @@ function initResumableUpload() {
}); });
resumableInstance.on("fileSuccess", function(file, message) { resumableInstance.on("fileSuccess", function(file, message) {
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`); // Try to parse JSON response
let data;
try {
data = JSON.parse(message);
} catch (e) {
data = null;
}
// 1) Softfail CSRF? then update token & retry this file
if (data && data.csrf_expired) {
// Update global and Resumable headers
window.csrfToken = data.csrf_token;
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
resumableInstance.opts.query.upload_token = data.csrf_token;
// Retry this chunk/file
file.retry();
return;
}
// 2) Otherwise treat as real success:
const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
);
if (li && li.progressBar) { if (li && li.progressBar) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
// Hide pause/resume and remove buttons for successful files. // remove action buttons
const pauseResumeBtn = li.querySelector(".pause-resume-btn"); const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) { if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
pauseResumeBtn.style.display = "none";
}
const removeBtn = li.querySelector(".remove-file-btn"); const removeBtn = li.querySelector(".remove-file-btn");
if (removeBtn) { if (removeBtn) removeBtn.style.display = "none";
removeBtn.style.display = "none"; setTimeout(() => li.remove(), 5000);
}
// Schedule removal of the file entry after 5 seconds.
setTimeout(() => {
li.remove();
window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier);
updateFileInfoCount();
}, 5000);
} }
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
}); });
@@ -618,8 +637,25 @@ function submitFiles(allFiles) {
} catch (e) { } catch (e) {
jsonResponse = null; jsonResponse = null;
} }
// ─── Soft-fail CSRF: retry this upload ───────────────────────
if (jsonResponse && jsonResponse.csrf_expired) {
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
// 1) update global token + header
window.csrfToken = jsonResponse.csrf_token;
xhr.open("POST", "/api/upload/upload.php", true);
xhr.withCredentials = true;
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
// 2) re-send the same formData
xhr.send(formData);
return; // skip the "finishedCount++" and error/success logic for now
}
// ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) { if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) { if (li) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
@@ -627,11 +663,14 @@ function submitFiles(allFiles) {
} }
uploadResults[file.uploadIndex] = true; uploadResults[file.uploadIndex] = true;
} else { } else {
// real failure
if (li) { if (li) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
allSucceeded = false; allSucceeded = false;
} }
// ─── Only now count this chunk as finished ───────────────────
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements); refreshFileList(allFiles, uploadResults, progressElements);
@@ -665,6 +704,7 @@ function submitFiles(allFiles) {
}); });
xhr.open("POST", "/api/upload/upload.php", true); xhr.open("POST", "/api/upload/upload.php", true);
xhr.withCredentials = true;
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken); xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
xhr.send(formData); xhr.send(formData);
}); });

View File

@@ -341,22 +341,27 @@ class AuthController
public function checkAuth(): void public function checkAuth(): void
{ {
header('Content-Type: application/json');
// 1) Remember-me re-login
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
if ($payload) {
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
session_regenerate_id(true);
$_SESSION['csrf_token'] = $old;
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $payload['username'];
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
// regenerate CSRF if you use one
// TOTP enabled? (same logic as below)
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
// setup mode?
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
error_log("checkAuth: setup mode");
echo json_encode(['setup' => true]);
exit();
}
if (empty($_SESSION['authenticated'])) {
echo json_encode(['authenticated' => false]);
exit();
}
// TOTP enabled?
$totp = false; $totp = false;
if (file_exists($usersFile)) {
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) { if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
@@ -364,17 +369,58 @@ class AuthController
break; break;
} }
} }
}
$isAdmin = ((int)AuthModel::getUserRole($_SESSION['username']) === 1); echo json_encode([
'authenticated' => true,
'csrf_token' => $_SESSION['csrf_token'],
'isAdmin' => $_SESSION['isAdmin'],
'totp_enabled' => $totp,
'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'],
'readOnly' => $_SESSION['readOnly'],
'disableUpload' => $_SESSION['disableUpload']
]);
exit();
}
}
$usersFile = USERS_DIR . USERS_FILE;
// 2) Setup mode?
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
error_log("checkAuth: setup mode");
echo json_encode(['setup' => true]);
exit();
}
// 3) Session-based auth
if (empty($_SESSION['authenticated'])) {
echo json_encode(['authenticated' => false]);
exit();
}
// 4) TOTP enabled?
$totp = false;
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(':', trim($line));
if ($parts[0] === ($_SESSION['username'] ?? '') && !empty($parts[3])) {
$totp = true;
break;
}
}
// 5) Final response
$resp = [ $resp = [
'authenticated' => true, 'authenticated' => true,
'isAdmin' => $isAdmin, 'isAdmin' => !empty($_SESSION['isAdmin']),
'totp_enabled' => $totp, 'totp_enabled' => $totp,
'username' => $_SESSION['username'], 'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'] ?? false, 'folderOnly' => $_SESSION['folderOnly'] ?? false,
'readOnly' => $_SESSION['readOnly'] ?? false, 'readOnly' => $_SESSION['readOnly'] ?? false,
'disableUpload' => $_SESSION['disableUpload'] ?? false 'disableUpload' => $_SESSION['disableUpload'] ?? false
]; ];
echo json_encode($resp); echo json_encode($resp);
exit(); exit();
} }
@@ -403,10 +449,19 @@ class AuthController
*/ */
public function getToken(): void public function getToken(): void
{ {
// 1) Ensure session and CSRF token exist
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 2) Emit headers
header('Content-Type: application/json'); header('Content-Type: application/json');
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
// 3) Return JSON payload
echo json_encode([ echo json_encode([
"csrf_token" => $_SESSION['csrf_token'], 'csrf_token' => $_SESSION['csrf_token'],
"share_url" => SHARE_URL 'share_url' => SHARE_URL
]); ]);
exit; exit;
} }

View File

@@ -73,33 +73,55 @@ class UploadController {
public function handleUpload(): void { public function handleUpload(): void {
header('Content-Type: application/json'); header('Content-Type: application/json');
// CSRF Protection. //
// 1) CSRF pull from header or POST fields
//
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? ''; $received = '';
if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) { if (!empty($headersArr['x-csrf-token'])) {
http_response_code(403); $received = trim($headersArr['x-csrf-token']);
echo json_encode(["error" => "Invalid CSRF token"]); } elseif (!empty($_POST['csrf_token'])) {
$received = trim($_POST['csrf_token']);
} elseif (!empty($_POST['upload_token'])) {
$received = trim($_POST['upload_token']);
}
// 1a) If it doesnt match, soft-fail: send new token and let client retry
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// regenerate
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// tell client “please retry with this new token”
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token']
]);
exit; exit;
} }
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { //
// 2) Auth checks
//
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
// Check user permissions. $userPerms = loadUserPermissions($_SESSION['username']);
$username = $_SESSION['username'] ?? ''; if (!empty($userPerms['disableUpload'])) {
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['disableUpload'])) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Upload disabled for this user."]); echo json_encode(["error" => "Upload disabled for this user."]);
exit; exit;
} }
// Delegate to the model. //
// 3) Delegate the actual file handling
//
$result = UploadModel::handleUpload($_POST, $_FILES); $result = UploadModel::handleUpload($_POST, $_FILES);
// For chunked uploads, output JSON (e.g., "chunk uploaded" status). //
// 4) Respond
//
if (isset($result['error'])) { if (isset($result['error'])) {
http_response_code(400); http_response_code(400);
echo json_encode($result); echo json_encode($result);
@@ -110,7 +132,7 @@ class UploadController {
exit; exit;
} }
// Otherwise, for full upload success, set a flash message and redirect. // fullupload redirect
$_SESSION['upload_message'] = "File uploaded successfully."; $_SESSION['upload_message'] = "File uploaded successfully.";
exit; exit;
} }

View File

@@ -87,63 +87,83 @@ class UserController
public function addUser() public function addUser()
{ {
// 1) Ensure JSON output and session
header('Content-Type: application/json'); header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE; // 1a) Initialize CSRF token if missing
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Determine if we're in setup mode. // 2) Determine setup mode (first-ever admin creation)
// Setup mode means the "setup" query parameter is passed $usersFile = USERS_DIR . USERS_FILE;
// and users.txt is missing, empty, or contains only whitespace.
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) { $setupMode = false;
// Allow initial admin creation without session or CSRF checks. if (
$isSetup && (! file_exists($usersFile)
|| filesize($usersFile) === 0
|| trim(file_get_contents($usersFile)) === ''
)
) {
$setupMode = true; $setupMode = true;
} else { } else {
$setupMode = false; // 3) In non-setup, enforce CSRF + auth checks
// In non-setup mode, perform CSRF token and authentication checks.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; $receivedToken = trim($headersArr['x-csrf-token'] ?? '');
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403); // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
echo json_encode(["error" => "Invalid CSRF token"]); if ($receivedToken !== $_SESSION['csrf_token']) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token']
]);
exit; exit;
} }
// 3b) Must be logged in as admin
if ( if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || empty($_SESSION['authenticated'])
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true || $_SESSION['authenticated'] !== true
|| empty($_SESSION['isAdmin'])
|| $_SESSION['isAdmin'] !== true
) { ) {
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
} }
// Get the JSON input data. // 4) Parse input
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents('php://input'), true) ?: [];
$newUsername = trim($data["username"] ?? ""); $newUsername = trim($data['username'] ?? '');
$newPassword = trim($data["password"] ?? ""); $newPassword = trim($data['password'] ?? '');
// In setup mode, force the new user to be an admin. // 5) Determine admin flag
if ($setupMode) { if ($setupMode) {
$isAdmin = "1"; $isAdmin = '1';
} else { } else {
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0"; $isAdmin = !empty($data['isAdmin']) ? '1' : '0';
} }
// Validate that a username and password are provided. // 6) Validate fields
if (!$newUsername || !$newPassword) { if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]); echo json_encode(["error" => "Username and password required"]);
exit; exit;
} }
// Validate username format.
if (!preg_match(REGEX_USER, $newUsername)) { if (!preg_match(REGEX_USER, $newUsername)) {
echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]); echo json_encode([
"error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."
]);
exit; exit;
} }
// Delegate the business logic to the model. // 7) Delegate to model
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); $result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
// 8) Return model result
echo json_encode($result); echo json_encode($result);
exit;
} }
/** /**
@@ -890,7 +910,10 @@ class UserController
// TFA helper // TFA helper
$tfa = new \RobThree\Auth\TwoFactorAuth( $tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 'FileRise',
6,
30,
\RobThree\Auth\Algorithm::Sha1
); );
// Pendinglogin flow (first password step passed) // Pendinglogin flow (first password step passed)
@@ -917,10 +940,11 @@ class UserController
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all = json_decode($dec, true) ?: []; $all = json_decode($dec, true) ?: [];
} }
$isAdmin = ((int)userModel::getUserRole($username) === 1);
$all[$token] = [ $all[$token] = [
'username' => $username, 'username' => $username,
'expiry' => $expiry, 'expiry' => $expiry,
'isAdmin' => $_SESSION['isAdmin'] 'isAdmin' => $isAdmin
]; ];
file_put_contents( file_put_contents(
$tokFile, $tokFile,
@@ -949,7 +973,7 @@ class UserController
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['username'] = $username; $_SESSION['username'] = $username;
$_SESSION['isAdmin'] = (userModel::getUserRole($username) === "1"); $_SESSION['isAdmin'] = $isAdmin;
$_SESSION['folderOnly'] = loadUserPermissions($username); $_SESSION['folderOnly'] = loadUserPermissions($username);
// Clean up // Clean up

View File

@@ -3,7 +3,8 @@
require_once PROJECT_ROOT . '/config/config.php'; require_once PROJECT_ROOT . '/config/config.php';
class AuthModel { class AuthModel
{
/** /**
* Retrieves the user's role from the users file. * Retrieves the user's role from the users file.
@@ -11,7 +12,8 @@ class AuthModel {
* @param string $username * @param string $username
* @return string|null The role string (e.g. "1" for admin) or null if not found. * @return string|null The role string (e.g. "1" for admin) or null if not found.
*/ */
public static function getUserRole(string $username): ?string { public static function getUserRole(string $username): ?string
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (file_exists($usersFile)) { if (file_exists($usersFile)) {
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
@@ -31,7 +33,8 @@ class AuthModel {
* @param string $password * @param string $password
* @return array|false Returns an associative array with user data (role, totp_secret) on success or false on failure. * @return array|false Returns an associative array with user data (role, totp_secret) on success or false on failure.
*/ */
public static function authenticate(string $username, string $password) { public static function authenticate(string $username, string $password)
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return false; return false;
@@ -58,7 +61,8 @@ class AuthModel {
* @param string $file * @param string $file
* @return array * @return array
*/ */
public static function loadFailedAttempts(string $file): array { public static function loadFailedAttempts(string $file): array
{
if (file_exists($file)) { if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true); $data = json_decode(file_get_contents($file), true);
if (is_array($data)) { if (is_array($data)) {
@@ -75,7 +79,8 @@ class AuthModel {
* @param array $data * @param array $data
* @return void * @return void
*/ */
public static function saveFailedAttempts(string $file, array $data): void { public static function saveFailedAttempts(string $file, array $data): void
{
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX); file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
} }
@@ -85,7 +90,8 @@ class AuthModel {
* @param string $username * @param string $username
* @return string|null Returns the decrypted TOTP secret or null if not set. * @return string|null Returns the decrypted TOTP secret or null if not set.
*/ */
public static function getUserTOTPSecret(string $username): ?string { public static function getUserTOTPSecret(string $username): ?string
{
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return null; return null;
@@ -105,7 +111,8 @@ class AuthModel {
* @param string $username * @param string $username
* @return bool * @return bool
*/ */
public static function loadFolderPermission(string $username): bool { public static function loadFolderPermission(string $username): bool
{
$permissionsFile = USERS_DIR . 'userPermissions.json'; $permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile); $content = file_get_contents($permissionsFile);
@@ -121,4 +128,31 @@ class AuthModel {
} }
return false; return false;
} }
/**
* Validate a remember-me token and return its stored payload.
*
* @param string $token
* @return array|null Returns ['username'=>…, 'expiry'=>…, 'isAdmin'=>…] or null if invalid/expired.
*/
public static function validateRememberToken(string $token): ?array
{
$tokFile = USERS_DIR . 'persistent_tokens.json';
if (! file_exists($tokFile)) {
return null;
}
// Decrypt and decode the full token store
$encrypted = file_get_contents($tokFile);
$json = decryptData($encrypted, $GLOBALS['encryptionKey']);
$all = json_decode($json, true) ?: [];
// Lookup and expiry check
if (empty($all[$token]) || !isset($all[$token]['expiry']) || $all[$token]['expiry'] < time()) {
return null;
}
// Valid token—return its payload
return $all[$token];
}
} }

192
start.sh
View File

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