Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078db33458 | ||
|
|
04f5cbe31f | ||
|
|
b5a7d8d559 | ||
|
|
58f8485b02 | ||
|
|
3e1da9c335 | ||
|
|
6bf6206e1c | ||
|
|
f9c60951c9 |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 4/24/2025 1.2.5
|
||||||
|
|
||||||
|
- Enhance README and wiki with expanded installation instructions
|
||||||
|
- Adjusted Dockerfile’s Apache vhost to:
|
||||||
|
- Alias `/uploads/` to `/var/www/uploads/` with PHP engine disabled and directory indexes off
|
||||||
|
- Disable HTTP TRACE and tune keep-alive (On, max 100 requests, 5s timeout) and server Timeout (60s)
|
||||||
|
- Add security headers (`X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`, `Referrer-Policy`)
|
||||||
|
- Enable `mod_deflate` compression for HTML, plain text, CSS, JS and JSON
|
||||||
|
- Configure `mod_expires` caching for images (1 month), CSS (1 week) and JS (3 hour)
|
||||||
|
- Deny access to hidden files (dot-files)
|
||||||
|
- Add access control in public/.htaccess for api.html & openapi.json; update Nginx example in wiki
|
||||||
|
- Remove obsolete folders from repo root
|
||||||
|
- Embed API documentation (`api.html`) directly in the FileRise UI as a full-screen modal
|
||||||
|
- Introduced `openApiModalBtn` in the user panel to launch the API modal
|
||||||
|
- Added `#apiModal` container with a same-origin `<iframe src="api.html">` so session cookies authenticate automatically
|
||||||
|
- Close control uses the existing `.editor-close-btn` for consistent styling and hover effects
|
||||||
|
|
||||||
## Changes 4/23/2025 1.2.4
|
## Changes 4/23/2025 1.2.4
|
||||||
|
|
||||||
**AuthModel**
|
**AuthModel**
|
||||||
@@ -30,6 +47,21 @@
|
|||||||
- **start.sh**
|
- **start.sh**
|
||||||
- Session directory setup
|
- Session directory setup
|
||||||
|
|
||||||
|
- Always sends `credentials: 'include'` and `X-CSRF-Token: window.csrfToken` s
|
||||||
|
- On HTTP 403, automatically fetches a fresh CSRF token (from the response header or `/api/auth/token.php`) and retries the request once
|
||||||
|
- Always returns the real `Response` object (no more “clone.json” on every 200)
|
||||||
|
- Now calls `fetchWithCsrf('/api/auth/token.php')` to guarantee a fresh token
|
||||||
|
- Checks `res.ok`, then parses JSON to extract `csrf_token` and `share_url`
|
||||||
|
- Updates both `window.csrfToken` and the `<meta name="csrf-token">` & `<meta name="share-url">` tags
|
||||||
|
- Removed Old CSRF logic that cloned every successful response and parsed its JSON body
|
||||||
|
- Removed Any “soft-failure” JSON peek on non-403 responses
|
||||||
|
- Add missing permissions in `UserModel.php` for TOTP login.
|
||||||
|
- **Prevent XSS in breadcrumbs**
|
||||||
|
- Replaced `innerHTML` calls in `fileListTitle` with a new `updateBreadcrumbTitle()` helper that uses `textContent` + `DocumentFragment`.
|
||||||
|
- Introduced `renderBreadcrumbFragment()` to build each breadcrumb segment as a `<span class="breadcrumb-link" data-folder="…">` node.
|
||||||
|
- Added `setupBreadcrumbDelegation()` to handle clicks via event delegation on the container, eliminating per-element listeners.
|
||||||
|
- Removed any raw HTML concatenation to satisfy CodeQL and ensure all breadcrumb text is safely escaped.
|
||||||
|
|
||||||
## Changes 4/22/2025 v1.2.3
|
## Changes 4/22/2025 v1.2.3
|
||||||
|
|
||||||
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
|
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -62,19 +62,64 @@ RUN chown -R root:www-data /var/www && \
|
|||||||
# Apache site configuration
|
# Apache site configuration
|
||||||
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
|
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
|
# Global settings
|
||||||
|
TraceEnable off
|
||||||
|
KeepAlive On
|
||||||
|
MaxKeepAliveRequests 100
|
||||||
|
KeepAliveTimeout 5
|
||||||
|
Timeout 60
|
||||||
|
|
||||||
ServerAdmin webmaster@localhost
|
ServerAdmin webmaster@localhost
|
||||||
DocumentRoot /var/www/public
|
DocumentRoot /var/www/public
|
||||||
|
|
||||||
|
# Security headers for all responses
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-XSS-Protection "1; mode=block"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive on
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType text/css "access plus 1 week"
|
||||||
|
ExpiresByType application/javascript "access plus 3 hour"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Protect uploads directory
|
||||||
Alias /uploads/ /var/www/uploads/
|
Alias /uploads/ /var/www/uploads/
|
||||||
<Directory "/var/www/uploads/">
|
<Directory "/var/www/uploads/">
|
||||||
Options -Indexes
|
Options -Indexes
|
||||||
AllowOverride None
|
AllowOverride None
|
||||||
|
<IfModule mod_php7.c>
|
||||||
|
php_flag engine off
|
||||||
|
</IfModule>
|
||||||
|
<IfModule mod_php.c>
|
||||||
|
php_flag engine off
|
||||||
|
</IfModule>
|
||||||
Require all granted
|
Require all granted
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
# Public directory
|
||||||
<Directory "/var/www/public">
|
<Directory "/var/www/public">
|
||||||
AllowOverride All
|
AllowOverride All
|
||||||
Require all granted
|
Require all granted
|
||||||
DirectoryIndex index.html
|
DirectoryIndex index.html index.php
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
ErrorLog /var/www/metadata/log/error.log
|
ErrorLog /var/www/metadata/log/error.log
|
||||||
CustomLog /var/www/metadata/log/access.log combined
|
CustomLog /var/www/metadata/log/access.log combined
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# FileRise
|
# FileRise
|
||||||
|
|
||||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||||
Upload, organize, and share files through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||||
|
|
||||||
**4/3/2025 Video demo:**
|
**4/3/2025 Video demo:**
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ If you prefer to run FileRise on a traditional web server (LAMP stack or similar
|
|||||||
git clone https://github.com/error311/FileRise.git
|
git clone https://github.com/error311/FileRise.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Place the files into your web server’s directory (e.g., `/var/www/html/filerise`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
Place the files into your web server’s directory (e.g., `/var/www/public`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
||||||
|
|
||||||
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
|
- **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.)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ DirectoryIndex index.html
|
|||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
<FilesMatch "^(api\.html|openapi\.json)$">
|
||||||
|
Require valid-user
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Enforce HTTPS (optional)
|
# Enforce HTTPS (optional)
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|||||||
@@ -52,28 +52,24 @@ const originalFetch = window.fetch;
|
|||||||
* @returns {Promise<Response>}
|
* @returns {Promise<Response>}
|
||||||
*/
|
*/
|
||||||
export async function fetchWithCsrf(url, options = {}) {
|
export async function fetchWithCsrf(url, options = {}) {
|
||||||
options = { credentials: 'include', headers: {}, ...options };
|
// 1) Merge in credentials + header
|
||||||
options.headers['X-CSRF-Token'] = window.csrfToken;
|
options = {
|
||||||
|
credentials: 'include',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
options.headers = {
|
||||||
|
...(options.headers || {}),
|
||||||
|
'X-CSRF-Token': window.csrfToken,
|
||||||
|
};
|
||||||
|
|
||||||
// 1) First attempt using the original fetch
|
// 2) First attempt
|
||||||
let res = await originalFetch(url, options);
|
let res = await originalFetch(url, options);
|
||||||
|
|
||||||
// 2) Soft‐failure JSON check (200 + {csrf_expired})
|
// 3) If we got a 403, try to refresh token & retry
|
||||||
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) {
|
if (res.status === 403) {
|
||||||
|
// 3a) See if the server gave us a new token header
|
||||||
let newToken = res.headers.get('X-CSRF-Token');
|
let newToken = res.headers.get('X-CSRF-Token');
|
||||||
|
// 3b) Otherwise fall back to the /api/auth/token endpoint
|
||||||
if (!newToken) {
|
if (!newToken) {
|
||||||
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
|
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
|
||||||
if (tokRes.ok) {
|
if (tokRes.ok) {
|
||||||
@@ -82,17 +78,21 @@ export async function fetchWithCsrf(url, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
|
// 3c) Update global + meta
|
||||||
window.csrfToken = newToken;
|
window.csrfToken = newToken;
|
||||||
document.querySelector('meta[name="csrf-token"]').content = newToken;
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (meta) meta.content = newToken;
|
||||||
|
|
||||||
|
// 3d) Retry the original request with the new token
|
||||||
options.headers['X-CSRF-Token'] = newToken;
|
options.headers['X-CSRF-Token'] = newToken;
|
||||||
res = await originalFetch(url, options);
|
res = await originalFetch(url, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Return the real Response—no body peeking here!
|
||||||
return res;
|
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();
|
||||||
|
|||||||
@@ -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.4"; // Update this version string as needed
|
const version = "v1.2.5"; // Update this version string as needed
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
@@ -230,14 +230,36 @@ export function openUserPanel() {
|
|||||||
|
|
||||||
<!-- New API Docs link -->
|
<!-- New API Docs link -->
|
||||||
<div style="margin-bottom: 15px;">
|
<div style="margin-bottom: 15px;">
|
||||||
<a href="api.html" target="_blank" class="btn btn-secondary">
|
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
|
||||||
${t("api_docs") || "API Docs"}
|
${t("api_docs") || "API Docs"}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(userPanelModal);
|
document.body.appendChild(userPanelModal);
|
||||||
|
|
||||||
|
const apiModal = document.createElement("div");
|
||||||
|
apiModal.id = "apiModal";
|
||||||
|
apiModal.style.cssText = `
|
||||||
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
`;
|
||||||
|
apiModal.innerHTML = `
|
||||||
|
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||||||
|
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||||||
|
<iframe src="api.html" style="width:100%;height:100%;border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(apiModal);
|
||||||
|
|
||||||
|
document.getElementById("openApiModalBtn").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "flex";
|
||||||
|
});
|
||||||
|
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
// Handlers…
|
// Handlers…
|
||||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
||||||
userPanelModal.style.display = "none";
|
userPanelModal.style.display = "none";
|
||||||
@@ -246,6 +268,7 @@ export function openUserPanel() {
|
|||||||
document.getElementById("changePasswordModal").style.display = "block";
|
document.getElementById("changePasswordModal").style.display = "block";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// TOTP checkbox
|
// TOTP checkbox
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
||||||
|
|||||||
@@ -104,24 +104,26 @@ export function setupBreadcrumbDelegation() {
|
|||||||
|
|
||||||
// Click handler via delegation
|
// Click handler via delegation
|
||||||
function breadcrumbClickHandler(e) {
|
function breadcrumbClickHandler(e) {
|
||||||
|
// find the nearest .breadcrumb-link
|
||||||
const link = e.target.closest(".breadcrumb-link");
|
const link = e.target.closest(".breadcrumb-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const folder = link.getAttribute("data-folder");
|
const folder = link.dataset.folder;
|
||||||
window.currentFolder = folder;
|
window.currentFolder = folder;
|
||||||
localStorage.setItem("lastOpenedFolder", folder);
|
localStorage.setItem("lastOpenedFolder", folder);
|
||||||
|
|
||||||
// Update the container with sanitized breadcrumbs.
|
// rebuild the title safely
|
||||||
const container = document.getElementById("fileListTitle");
|
updateBreadcrumbTitle(folder);
|
||||||
const sanitizedBreadcrumb = DOMPurify.sanitize(renderBreadcrumb(folder));
|
|
||||||
container.innerHTML = t("files_in") + " (" + sanitizedBreadcrumb + ")";
|
|
||||||
|
|
||||||
expandTreePath(folder);
|
expandTreePath(folder);
|
||||||
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
document.querySelectorAll(".folder-option").forEach(el =>
|
||||||
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
el.classList.remove("selected")
|
||||||
if (targetOption) targetOption.classList.add("selected");
|
);
|
||||||
|
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||||
|
if (target) target.classList.add("selected");
|
||||||
|
|
||||||
loadFileList(folder);
|
loadFileList(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +337,38 @@ function folderDropHandler(event) {
|
|||||||
/* ----------------------
|
/* ----------------------
|
||||||
Main Folder Tree Rendering and Event Binding
|
Main Folder Tree Rendering and Event Binding
|
||||||
----------------------*/
|
----------------------*/
|
||||||
|
// --- Helpers for safe breadcrumb rendering ---
|
||||||
|
function renderBreadcrumbFragment(folderPath) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const parts = folderPath.split("/");
|
||||||
|
let acc = "";
|
||||||
|
|
||||||
|
parts.forEach((part, idx) => {
|
||||||
|
acc = idx === 0 ? part : acc + "/" + part;
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.classList.add("breadcrumb-link");
|
||||||
|
span.dataset.folder = acc;
|
||||||
|
span.textContent = part;
|
||||||
|
frag.appendChild(span);
|
||||||
|
|
||||||
|
if (idx < parts.length - 1) {
|
||||||
|
frag.appendChild(document.createTextNode(" / "));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBreadcrumbTitle(folder) {
|
||||||
|
const titleEl = document.getElementById("fileListTitle");
|
||||||
|
titleEl.textContent = "";
|
||||||
|
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
||||||
|
titleEl.appendChild(renderBreadcrumbFragment(folder));
|
||||||
|
titleEl.appendChild(document.createTextNode(")"));
|
||||||
|
setupBreadcrumbDelegation();
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadFolderTree(selectedFolder) {
|
export async function loadFolderTree(selectedFolder) {
|
||||||
try {
|
try {
|
||||||
// Check if the user has folder-only permission.
|
// Check if the user has folder-only permission.
|
||||||
@@ -420,9 +454,8 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
}
|
}
|
||||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||||
|
|
||||||
const titleEl = document.getElementById("fileListTitle");
|
// Initial breadcrumb update
|
||||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
|
updateBreadcrumbTitle(window.currentFolder);
|
||||||
setupBreadcrumbDelegation();
|
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
|
||||||
const folderState = loadFolderTreeState();
|
const folderState = loadFolderTreeState();
|
||||||
@@ -436,6 +469,7 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
selectedEl.classList.add("selected");
|
selectedEl.classList.add("selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder-option click: update selection, breadcrumbs, and file list
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
container.querySelectorAll(".folder-option").forEach(el => {
|
||||||
el.addEventListener("click", function (e) {
|
el.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -444,13 +478,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
const selected = this.getAttribute("data-folder");
|
const selected = this.getAttribute("data-folder");
|
||||||
window.currentFolder = selected;
|
window.currentFolder = selected;
|
||||||
localStorage.setItem("lastOpenedFolder", selected);
|
localStorage.setItem("lastOpenedFolder", selected);
|
||||||
const titleEl = document.getElementById("fileListTitle");
|
|
||||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(selected) + ")";
|
// Safe breadcrumb update
|
||||||
setupBreadcrumbDelegation();
|
updateBreadcrumbTitle(selected);
|
||||||
loadFileList(selected);
|
loadFileList(selected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Root toggle handler
|
||||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||||
if (rootToggle) {
|
if (rootToggle) {
|
||||||
rootToggle.addEventListener("click", function (e) {
|
rootToggle.addEventListener("click", function (e) {
|
||||||
@@ -474,6 +509,7 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Other folder-toggle handlers
|
||||||
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
||||||
toggle.addEventListener("click", function (e) {
|
toggle.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -502,6 +538,7 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// For backward compatibility.
|
// For backward compatibility.
|
||||||
export function loadFolderList(selectedFolder) {
|
export function loadFolderList(selectedFolder) {
|
||||||
loadFolderTree(selectedFolder);
|
loadFolderTree(selectedFolder);
|
||||||
|
|||||||
@@ -14,36 +14,20 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
|
|||||||
import { editFile, saveFile } from './fileEditor.js';
|
import { editFile, saveFile } from './fileEditor.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
|
|
||||||
// Remove the retry logic version and just use loadCsrfToken directly:
|
|
||||||
/**
|
|
||||||
* 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() {
|
export function loadCsrfToken() {
|
||||||
return fetch('/api/auth/token.php', {
|
return fetchWithCsrf('/api/auth/token.php', {
|
||||||
method: 'GET',
|
method: 'GET'
|
||||||
credentials: 'include'
|
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(res => {
|
||||||
if (!response.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Token fetch failed with status: ${response.status}`);
|
throw new Error(`Token fetch failed with status ${res.status}`);
|
||||||
}
|
}
|
||||||
// Prefer header if set, otherwise fall back to body
|
return res.json();
|
||||||
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(({ csrf_token, share_url }) => {
|
.then(({ csrf_token, share_url }) => {
|
||||||
// Update globals
|
// Update global and <meta>
|
||||||
window.csrfToken = csrf_token;
|
window.csrfToken = csrf_token;
|
||||||
window.SHARE_URL = share_url;
|
|
||||||
|
|
||||||
// Sync <meta name="csrf-token">
|
|
||||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
meta = document.createElement('meta');
|
meta = document.createElement('meta');
|
||||||
@@ -52,7 +36,6 @@ export function loadCsrfToken() {
|
|||||||
}
|
}
|
||||||
meta.content = csrf_token;
|
meta.content = csrf_token;
|
||||||
|
|
||||||
// Sync <meta name="share-url">
|
|
||||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||||
if (!shareMeta) {
|
if (!shareMeta) {
|
||||||
shareMeta = document.createElement('meta');
|
shareMeta = document.createElement('meta');
|
||||||
|
|||||||
@@ -867,126 +867,123 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function verifyTOTP()
|
public function verifyTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||||
|
|
||||||
// Rate‑limit
|
// Rate-limit
|
||||||
if (!isset($_SESSION['totp_failures'])) {
|
if (!isset($_SESSION['totp_failures'])) {
|
||||||
$_SESSION['totp_failures'] = 0;
|
$_SESSION['totp_failures'] = 0;
|
||||||
}
|
}
|
||||||
if ($_SESSION['totp_failures'] >= 5) {
|
if ($_SESSION['totp_failures'] >= 5) {
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be authenticated OR pending login
|
// Must be authenticated OR pending login
|
||||||
if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) {
|
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
||||||
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate input
|
// Parse & validate input
|
||||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
$inputData = json_decode(file_get_contents("php://input"), true);
|
||||||
$code = trim($inputData['totp_code'] ?? '');
|
$code = trim($inputData['totp_code'] ?? '');
|
||||||
if (!preg_match('/^\d{6}$/', $code)) {
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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',
|
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
|
||||||
6,
|
);
|
||||||
30,
|
|
||||||
\RobThree\Auth\Algorithm::Sha1
|
// === Pending-login flow (we just came from auth and need to finish login) ===
|
||||||
);
|
if (isset($_SESSION['pending_login_user'])) {
|
||||||
|
$username = $_SESSION['pending_login_user'];
|
||||||
// Pending‑login flow (first password step passed)
|
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
||||||
if (isset($_SESSION['pending_login_user'])) {
|
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
||||||
$username = $_SESSION['pending_login_user'];
|
|
||||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
||||||
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
$_SESSION['totp_failures']++;
|
||||||
|
http_response_code(400);
|
||||||
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
||||||
$_SESSION['totp_failures']++;
|
exit;
|
||||||
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';
|
||||||
// === Issue “remember me” token if requested ===
|
$token = bin2hex(random_bytes(32));
|
||||||
if ($rememberMe) {
|
$expiry = time() + 30 * 24 * 60 * 60;
|
||||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
$all = [];
|
||||||
$token = bin2hex(random_bytes(32));
|
if (file_exists($tokFile)) {
|
||||||
$expiry = time() + 30 * 24 * 60 * 60;
|
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||||
$all = [];
|
$all = json_decode($dec, true) ?: [];
|
||||||
|
}
|
||||||
if (file_exists($tokFile)) {
|
$all[$token] = [
|
||||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
'username' => $username,
|
||||||
$all = json_decode($dec, true) ?: [];
|
'expiry' => $expiry,
|
||||||
}
|
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
||||||
$isAdmin = ((int)userModel::getUserRole($username) === 1);
|
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
||||||
$all[$token] = [
|
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
||||||
'username' => $username,
|
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false
|
||||||
'expiry' => $expiry,
|
];
|
||||||
'isAdmin' => $isAdmin
|
file_put_contents(
|
||||||
];
|
$tokFile,
|
||||||
file_put_contents(
|
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
||||||
$tokFile,
|
LOCK_EX
|
||||||
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
);
|
||||||
LOCK_EX
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
);
|
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||||
|
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
||||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
}
|
||||||
|
|
||||||
// Persistent cookie
|
// === Finalize login into session exactly as finalizeLogin() would ===
|
||||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['authenticated'] = true;
|
||||||
// Re‑issue PHP session cookie
|
$_SESSION['username'] = $username;
|
||||||
setcookie(
|
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
||||||
session_name(),
|
$perms = loadUserPermissions($username);
|
||||||
session_id(),
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||||
$expiry,
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||||
'/',
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
'',
|
|
||||||
$secure,
|
// Clean up pending markers
|
||||||
true
|
unset(
|
||||||
);
|
$_SESSION['pending_login_user'],
|
||||||
}
|
$_SESSION['pending_login_secret'],
|
||||||
|
$_SESSION['pending_login_remember_me'],
|
||||||
// Finalize login
|
$_SESSION['totp_failures']
|
||||||
session_regenerate_id(true);
|
);
|
||||||
$_SESSION['authenticated'] = true;
|
|
||||||
$_SESSION['username'] = $username;
|
// Send back full login payload
|
||||||
$_SESSION['isAdmin'] = $isAdmin;
|
echo json_encode([
|
||||||
$_SESSION['folderOnly'] = loadUserPermissions($username);
|
'status' => 'ok',
|
||||||
|
'success' => 'Login successful',
|
||||||
// Clean up
|
'isAdmin' => $_SESSION['isAdmin'],
|
||||||
unset(
|
'folderOnly' => $_SESSION['folderOnly'],
|
||||||
$_SESSION['pending_login_user'],
|
'readOnly' => $_SESSION['readOnly'],
|
||||||
$_SESSION['pending_login_secret'],
|
'disableUpload' => $_SESSION['disableUpload'],
|
||||||
$_SESSION['pending_login_remember_me'],
|
'username' => $_SESSION['username']
|
||||||
$_SESSION['totp_failures']
|
]);
|
||||||
);
|
exit;
|
||||||
|
}
|
||||||
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup/verification flow (not pending)
|
// Setup/verification flow (not pending)
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<IfModule mod_php7.c>
|
|
||||||
php_flag engine off
|
|
||||||
</IfModule>
|
|
||||||
<IfModule mod_php.c>
|
|
||||||
php_flag engine off
|
|
||||||
</IfModule>
|
|
||||||
Options -Indexes
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<Files "users.txt">
|
|
||||||
Require all denied
|
|
||||||
</Files>
|
|
||||||
Reference in New Issue
Block a user