Compare commits

..

9 Commits

18 changed files with 975 additions and 561 deletions

View File

@@ -1,5 +1,66 @@
# Changelog # Changelog
## Changes 4/30/2025 v1.2.8
- **Added** PDF preview in `filePreview.js` (the `extension === "pdf"` block): replaced in-modal `<embed>` with `window.open(urlWithTs, "_blank")` and closed the modal to avoid CSP `frame-ancestors 'none'` restrictions.
- **Added** `autofocus` attribute to the login forms username input (`#loginUsername`) so the cursor is ready for typing on page load.
- **Enhanced** login initialization with a `DOMContentLoaded` fallback that calls `loginUsername.focus()` (via `setTimeout`) if needed.
- **Set** focus to the “New Username” field (`#newUsername`) when entering setup mode, hiding the login form and showing the Add-User modal.
- **Implemented** Enter-key support in setup mode by attaching `attachEnterKeyListener("addUserModal", "saveUserBtn")`, allowing users to press Enter to submit the Add-User form.
---
## Changes 4/28/2025
**Added**
- **Custom expiration** option to File Share modal
- Users can specify a value + unit (seconds, minutes, hours, days)
- Displays a warning when a custom duration is selected
- **Custom expiration** option to Folder Share modal (same value+unit picker and warning)
**Changed**
- **API parameters** for both endpoints:
- Replaced `expirationMinutes` with `expirationValue` + `expirationUnit`
- Front-end now sends `{ expirationValue, expirationUnit }`
- Back-end converts those into total seconds before saving
- **UI**
- FileShare and FolderShare modals updated to handle “Custom…” selection
**Updated Models & Controllers**
- **FileModel::createShareLink** now accepts expiration in seconds
- **FolderModel::createShareFolderLink** now accepts expiration in seconds
- **createShareLink.php** & **createShareFolderLink.php** updated to parse and convert new parameters
**Documentation**
- OpenAPI annotations for both endpoints updated to require `expirationValue` + `expirationUnit` (enum: seconds, minutes, hours, days)
## Changes 4/27/2025 v1.2.7
- **Select-All** checkbox now correctly toggles all `.file-checkbox` inputs
- Updated `toggleAllCheckboxes(masterCheckbox)` to call `updateRowHighlight()` on each row so selections get the `.row-selected` highlight
- **Master checkbox sync** in toolbar
- Enhanced `updateFileActionButtons()` to set the header checkbox to checked, unchecked, or indeterminate based on how many files are selected
- Fixed Pagination controls & Items-per-page dropdown
- Fixed `#advancedSearchToggle` in both `renderFileTable()` and `renderGalleryView()`
- **Shared folder gallery view logic**
- Introduced new `public/js/sharedFolderView.js` containing all DOMContentLoaded wiring, `toggleViewMode()`, gallery rendering, and event listeners
- Embedded a non-executing JSON payload in `shareFolder.php`
- **`FolderController::shareFolder()` / `shareFolder.php`**
- Removed all inline `onclick="…"` attributes and inline `<script>` blocks
- Added `<script type="application/json" id="shared-data">…</script>` to export `$token` and `$files`
- Added `<script src="/js/sharedFolderView.js" defer></script>` to load the external view logic
- **Styling updates**
- Added `.toggle-btn` CSS for blue header-style toggle button and applied it in JS
- Added `.pagination a:hover { background-color: #0056b3; }` to match button hover
- Tweaked `body` padding and `header h1` margins to reduce whitespace above header
- Refactored `sharedFolderView.js:renderGalleryView()` to eliminate `innerHTML` usage; now uses `document.createElement` and `textContent` so filenames and URLs are fully escaped and CSP-safe
---
## Changes 4/26/2025 1.2.6 ## Changes 4/26/2025 1.2.6
**Apache / Dockerfile (CSP)** **Apache / Dockerfile (CSP)**
@@ -49,6 +110,8 @@
- **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders. - **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders.
- **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant. - **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant.
- Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready. - Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready.
- Added Content-Security-Policy for `<Files "api.php">` block to allow embedding the ReDoc iframe.
- Extracted inline ReDoc init into `public/js/redoc-init.js` and updated `public/api.php` to use deferred `<script>` tags.
--- ---
@@ -208,7 +271,7 @@
Refactored to: Refactored to:
1. Fetch CSRF 1. Fetch CSRF
2. POST credentials to `/api/auth/auth.php` 2. POST credentials to `/api/auth/auth.php`
3. On `totp_required`, refetch CSRF *again* before calling `openTOTPLoginModal()` 3. On `totp_required`, refetch CSRF again before calling `openTOTPLoginModal()`
4. Handle full logins vs. TOTP flows cleanly. 4. Handle full logins vs. TOTP flows cleanly.
- **TOTP handlers update** - **TOTP handlers update**
@@ -1154,7 +1217,7 @@ The enhancements extend the existing drag-and-drop functionality by adding a hea
- Adjusted file preview and icon styling for better alignment. - Adjusted file preview and icon styling for better alignment.
- Centered the header and optimized the layout for a clean, modern appearance. - Centered the header and optimized the layout for a clean, modern appearance.
*This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.* This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.
--- ---

View File

@@ -120,6 +120,10 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<FilesMatch "^\."> <FilesMatch "^\.">
Require all denied Require all denied
</FilesMatch> </FilesMatch>
<Files "api.php">
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
</Files>
ErrorLog /var/www/metadata/log/error.log ErrorLog /var/www/metadata/log/error.log
CustomLog /var/www/metadata/log/access.log combined CustomLog /var/www/metadata/log/access.log combined
@@ -127,7 +131,7 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
EOF EOF
# Enable required modules # Enable required modules
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
EXPOSE 80 443 EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh COPY start.sh /usr/local/bin/start.sh

View File

@@ -108,16 +108,16 @@ FileRise will be accessible at `http://localhost:8080` (or your servers IP).
If you prefer to run FileRise on a traditional web server (LAMP stack or similar): If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
- **Requirements:** PHP 8.1 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed. - **Requirements:** PHP 8.3 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases). - **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
``` bash ``` bash
git clone https://github.com/error311/FileRise.git git clone https://github.com/error311/FileRise.git
``` ```
Place the files into your web servers directory (e.g., `/var/www/public`). It can be in a subfolder (just adjust the `BASE_URL` in config as below). Place the files into your web servers directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.) - **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist): - **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist):

View File

@@ -19,17 +19,13 @@ if (isset($_GET['spec'])) {
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title> <title>FileRise API Docs</title>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" <script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX" integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script defer src="/js/redoc-init.js"></script>
</head> </head>
<body> <body>
<redoc spec-url="api.php?spec=1"></redoc> <redoc spec-url="api.php?spec=1"></redoc>
<div id="redoc-container"></div> <div id="redoc-container"></div>
<script>
if (!customElements.get('redoc')) {
Redoc.init('api.php?spec=1', {}, document.getElementById('redoc-container'));
}
</script>
</body> </body>
</html> </html>

View File

@@ -182,7 +182,7 @@
<form id="authForm" method="post"> <form id="authForm" method="post">
<div class="form-group"> <div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label> <label for="loginUsername" data-i18n-key="user">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required /> <input type="text" class="form-control" id="loginUsername" name="username" required autofocus />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="loginPassword" data-i18n-key="password">Password:</label> <label for="loginPassword" data-i18n-key="password">Password:</label>
@@ -442,18 +442,30 @@
<div id="addUserModal" class="modal"> <div id="addUserModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="create_new_user_title">Create New User</h3> <h3 data-i18n-key="create_new_user_title">Create New User</h3>
<label for="newUsername" data-i18n-key="username">Username:</label> <!-- 1) Add a form around these fields -->
<input type="text" id="newUsername" class="form-control" /> <form id="addUserForm">
<label for="addUserPassword" data-i18n-key="password">Password:</label> <label for="newUsername" data-i18n-key="username">Username:</label>
<input type="password" id="addUserPassword" class="form-control" /> <input type="text" id="newUsername" class="form-control" required />
<div id="adminCheckboxContainer">
<input type="checkbox" id="isAdmin" /> <label for="addUserPassword" data-i18n-key="password">Password:</label>
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label> <input type="password" id="addUserPassword" class="form-control" required />
</div>
<div class="button-container"> <div id="adminCheckboxContainer">
<button id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <input type="checkbox" id="isAdmin" />
<button id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">Save User</button> <label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
</div> </div>
<div class="button-container">
<!-- Cancel stays type="button" -->
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
Cancel
</button>
<!-- Save becomes type="submit" -->
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">
Save User
</button>
</div>
</form>
</div> </div>
</div> </div>
<div id="removeUserModal" class="modal"> <div id="removeUserModal" class="modal">

View File

@@ -125,10 +125,17 @@ function updateItemsPerPageSelect() {
} }
} }
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) { function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
const authForm = document.getElementById("authForm"); const authForm = document.getElementById("authForm");
if
if (authForm) authForm.style.display = disableFormLogin ? "none" : "block"; (authForm) {
authForm.style.display = disableFormLogin ? "none" : "block";
setTimeout(() => {
const loginInput = document.getElementById('loginUsername');
if (loginInput) loginInput.focus();
}, 0);
}
const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']"); const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']");
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block"; if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
const oidcLoginBtn = document.getElementById("oidcLoginBtn"); const oidcLoginBtn = document.getElementById("oidcLoginBtn");
@@ -187,7 +194,7 @@ function updateAuthenticatedUI(data) {
toggleVisibility("mainOperations", true); toggleVisibility("mainOperations", true);
toggleVisibility("uploadFileForm", true); toggleVisibility("uploadFileForm", true);
toggleVisibility("fileListContainer", true); toggleVisibility("fileListContainer", true);
attachEnterKeyListener("addUserModal", "saveUserBtn"); //attachEnterKeyListener("addUserModal", "saveUserBtn");
attachEnterKeyListener("removeUserModal", "deleteUserBtn"); attachEnterKeyListener("removeUserModal", "deleteUserBtn");
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn"); attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
document.querySelector(".header-buttons").style.visibility = "visible"; document.querySelector(".header-buttons").style.visibility = "visible";
@@ -443,34 +450,46 @@ function initAuth() {
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus(); document.getElementById("newUsername").focus();
}); });
document.getElementById("saveUserBtn").addEventListener("click", function () {
const newUsername = document.getElementById("newUsername").value.trim(); // remove your old saveUserBtn click-handler…
const newPassword = document.getElementById("addUserPassword").value.trim();
const isAdmin = document.getElementById("isAdmin").checked; // instead:
if (!newUsername || !newPassword) { const addUserForm = document.getElementById("addUserForm");
showToast("Username and password are required!"); addUserForm.addEventListener("submit", function (e) {
return; e.preventDefault(); // stop the browser from reloading the page
}
let url = "/api/addUser.php"; const newUsername = document.getElementById("newUsername").value.trim();
if (window.setupMode) url += "?setup=1"; const newPassword = document.getElementById("addUserPassword").value.trim();
fetchWithCsrf(url, { const isAdmin = document.getElementById("isAdmin").checked;
method: "POST",
credentials: "include", if (!newUsername || !newPassword) {
headers: { "Content-Type": "application/json" }, showToast("Username and password are required!");
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin }) return;
}
let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1";
fetchWithCsrf(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast("User added successfully!");
closeAddUserModal();
checkAuthentication(false);
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
}) })
.then(response => response.json()) .catch(() => {
.then(data => { showToast("Error: Could not add user");
if (data.success) { });
showToast("User added successfully!"); });
closeAddUserModal();
checkAuthentication(false);
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
})
.catch(() => { });
});
document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal); document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal);
document.getElementById("removeUserBtn").addEventListener("click", function () { document.getElementById("removeUserBtn").addEventListener("click", function () {

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.6"; // Update this version string as needed const version = "v1.2.8"; // 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

@@ -25,8 +25,9 @@ export function toggleAllCheckboxes(masterCheckbox) {
const checkboxes = document.querySelectorAll(".file-checkbox"); const checkboxes = document.querySelectorAll(".file-checkbox");
checkboxes.forEach(chk => { checkboxes.forEach(chk => {
chk.checked = masterCheckbox.checked; chk.checked = masterCheckbox.checked;
updateRowHighlight(chk);
}); });
updateFileActionButtons(); // update buttons based on current selection updateFileActionButtons();
} }
export function updateFileActionButtons() { export function updateFileActionButtons() {
@@ -38,6 +39,21 @@ export function updateFileActionButtons() {
const zipBtn = document.getElementById("downloadZipBtn"); const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn"); const extractZipBtn = document.getElementById("extractZipBtn");
// keep the “select all” in sync ——
const master = document.getElementById("selectAll");
if (master) {
if (selectedCheckboxes.length === fileCheckboxes.length) {
master.checked = true;
master.indeterminate = false;
} else if (selectedCheckboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
if (fileCheckboxes.length === 0) { if (fileCheckboxes.length === 0) {
if (copyBtn) copyBtn.style.display = "none"; if (copyBtn) copyBtn.style.display = "none";
if (moveBtn) moveBtn.style.display = "none"; if (moveBtn) moveBtn.style.display = "none";
@@ -271,8 +287,6 @@ export function toggleRowSelection(event, fileName) {
const start = Math.min(currentIndex, lastIndex); const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex);
// If neither CTRL nor Meta is pressed, you might choose
// to clear existing selections. For this example we leave existing selections intact.
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
const cb = allRows[i].querySelector(".file-checkbox"); const cb = allRows[i].querySelector(".file-checkbox");
if (cb) { if (cb) {

View File

@@ -340,47 +340,87 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// pagination clicks
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderFileTable(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
// totalPages is computed above in this scope
if (window.currentPage < totalPages) {
window.currentPage++;
renderFileTable(folder, container);
}
});
// ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// items-per-page selector
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderFileTable(folder, container);
});
// hook up the master checkbox
const selectAll = document.getElementById("selectAll");
if (selectAll) {
selectAll.addEventListener("change", () => {
toggleAllCheckboxes(selectAll);
});
}
// 1) Row-click selects the row // 1) Row-click selects the row
fileListContent.querySelectorAll("tbody tr").forEach(row => { fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => { row.addEventListener("click", e => {
// grab the underlying checkbox value // grab the underlying checkbox value
const cb = row.querySelector(".file-checkbox"); const cb = row.querySelector(".file-checkbox");
if (!cb) return; if (!cb) return;
toggleRowSelection(e, cb.value); toggleRowSelection(e, cb.value);
});
}); });
});
// 2) Download buttons
// 2) Download buttons fileListContent.querySelectorAll(".download-btn").forEach(btn => {
fileListContent.querySelectorAll(".download-btn").forEach(btn => { btn.addEventListener("click", e => {
btn.addEventListener("click", e => { e.stopPropagation();
e.stopPropagation(); openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder); });
}); });
});
// 3) Edit buttons
// 3) Edit buttons fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
fileListContent.querySelectorAll(".edit-btn").forEach(btn => { btn.addEventListener("click", e => {
btn.addEventListener("click", e => { e.stopPropagation();
e.stopPropagation(); editFile(btn.dataset.editName, btn.dataset.editFolder);
editFile(btn.dataset.editName, btn.dataset.editFolder); });
}); });
});
// 4) Rename buttons
// 4) Rename buttons fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
fileListContent.querySelectorAll(".rename-btn").forEach(btn => { btn.addEventListener("click", e => {
btn.addEventListener("click", e => { e.stopPropagation();
e.stopPropagation(); renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
renameFile(btn.dataset.renameName, btn.dataset.renameFolder); });
}); });
});
// 5) Preview buttons (if you still have a .preview-btn)
// 5) Preview buttons (if you still have a .preview-btn) fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
fileListContent.querySelectorAll(".preview-btn").forEach(btn => { btn.addEventListener("click", e => {
btn.addEventListener("click", e => { e.stopPropagation();
e.stopPropagation(); previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
previewFile(btn.dataset.previewUrl, btn.dataset.previewName); });
}); });
});
createViewToggleButton(); createViewToggleButton();
@@ -575,7 +615,7 @@ export function renderGalleryView(folder, container) {
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label> style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview" style="cursor:pointer;" <div class="gallery-preview" style="cursor:pointer;"
data-preview-url="${folderPath+encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${file.name}"> data-preview-name="${file.name}">
${thumbnail} ${thumbnail}
</div> </div>
@@ -590,20 +630,20 @@ export function renderGalleryView(folder, container) {
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;"> <div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
<button type="button" class="btn btn-sm btn-success download-btn" <button type="button" class="btn btn-sm btn-success download-btn"
data-download-name="${escapeHTML(file.name)}" data-download-name="${escapeHTML(file.name)}"
data-download-folder="${file.folder||"root"}" data-download-folder="${file.folder || "root"}"
title="${t('download')}"> title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button type="button" class="btn btn-sm edit-btn" <button type="button" class="btn btn-sm edit-btn"
data-edit-name="${escapeHTML(file.name)}" data-edit-name="${escapeHTML(file.name)}"
data-edit-folder="${file.folder||"root"}" data-edit-folder="${file.folder || "root"}"
title="${t('edit')}"> title="${t('edit')}">
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</button>` : ""} </button>` : ""}
<button type="button" class="btn btn-sm btn-warning rename-btn" <button type="button" class="btn btn-sm btn-warning rename-btn"
data-rename-name="${escapeHTML(file.name)}" data-rename-name="${escapeHTML(file.name)}"
data-rename-folder="${file.folder||"root"}" data-rename-folder="${file.folder || "root"}"
title="${t('rename')}"> title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
@@ -629,6 +669,40 @@ export function renderGalleryView(folder, container) {
// --- Now wire up all behaviors without inline handlers --- // --- Now wire up all behaviors without inline handlers ---
// ADD: pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderGalleryView(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
if (window.currentPage < totalPages) {
window.currentPage++;
renderGalleryView(folder, container);
}
});
// ←— ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// ←— ADD: wire up context-menu in gallery
bindFileListContextMenu();
// ADD: items-per-page selector for gallery
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderGalleryView(folder, container);
});
// cache images on load // cache images on load
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => { fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
const key = img.dataset.cacheKey; const key = img.dataset.cacheKey;

View File

@@ -4,36 +4,68 @@ import { fileData } from './fileListView.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
export function openShareModal(file, folder) { export function openShareModal(file, folder) {
// Remove any existing modal
const existing = document.getElementById("shareModal"); const existing = document.getElementById("shareModal");
if (existing) existing.remove(); if (existing) existing.remove();
// Build the modal
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.id = "shareModal"; modal.id = "shareModal";
modal.classList.add("modal"); modal.classList.add("modal");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content share-modal-content" style="width: 600px; max-width:90vw;"> <div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
<div class="modal-header"> <div class="modal-header">
<h3>${t("share_file")}: ${escapeHTML(file.name)}</h3> <h3>${t("share_file")}: ${escapeHTML(file.name)}</h3>
<span class="close-image-modal" id="closeShareModal" title="Close">&times;</span> <span id="closeShareModal" title="${t("close")}" class="close-image-modal">&times;</span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>${t("set_expiration")}</p> <p>${t("set_expiration")}</p>
<select id="shareExpiration"> <select id="shareExpiration" style="width:100%;padding:5px;">
<option value="30">30 minutes</option> <option value="30">30 ${t("minutes")}</option>
<option value="60" selected>60 minutes</option> <option value="60" selected>60 ${t("minutes")}</option>
<option value="120">120 minutes</option> <option value="120">120 ${t("minutes")}</option>
<option value="180">180 minutes</option> <option value="180">180 ${t("minutes")}</option>
<option value="240">240 minutes</option> <option value="240">240 ${t("minutes")}</option>
<option value="1440">1 Day</option> <option value="1440">1 ${t("day")}</option>
<option value="custom">${t("custom")}&hellip;</option>
</select> </select>
<p>${t("password_optional")}</p>
<input type="text" id="sharePassword" placeholder=${t("password_optional")} style="width: 100%;"/> <div id="customExpirationContainer" style="display:none;margin-top:10px;">
<br> <label for="customExpirationValue">${t("duration")}:</label>
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:10px;">${t("generate_share_link")}</button> <input type="number" id="customExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
<div id="shareLinkDisplay" style="margin-top: 10px; display:none;"> <select id="customExpirationUnit">
<option value="seconds">${t("seconds")}</option>
<option value="minutes" selected>${t("minutes")}</option>
<option value="hours">${t("hours")}</option>
<option value="days">${t("days")}</option>
</select>
<p class="share-warning" style="color:#a33;font-size:0.9em;margin-top:5px;">
${t("custom_duration_warning")}
</p>
</div>
<p style="margin-top:15px;">${t("password_optional")}</p>
<input
type="text"
id="sharePassword"
placeholder="${t("password_optional")}"
style="width:100%;padding:5px;"
/>
<button
id="generateShareLinkBtn"
class="btn btn-primary"
style="margin-top:15px;"
>
${t("generate_share_link")}
</button>
<div id="shareLinkDisplay" style="margin-top:15px;display:none;">
<p>${t("shareable_link")}</p> <p>${t("shareable_link")}</p>
<input type="text" id="shareLinkInput" readonly style="width:100%;"/> <input type="text" id="shareLinkInput" readonly style="width:100%;padding:5px;"/>
<button id="copyShareLinkBtn" class="btn btn-primary" style="margin-top:5px;">${t("copy_link")}</button> <button id="copyShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
${t("copy_link")}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -41,52 +73,72 @@ export function openShareModal(file, folder) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = "block"; modal.style.display = "block";
document.getElementById("closeShareModal").addEventListener("click", () => { // Close handler
modal.remove(); document.getElementById("closeShareModal")
}); .addEventListener("click", () => modal.remove());
document.getElementById("generateShareLinkBtn").addEventListener("click", () => { // Show/hide custom-duration inputs
const expiration = document.getElementById("shareExpiration").value; document.getElementById("shareExpiration")
const password = document.getElementById("sharePassword").value; .addEventListener("change", e => {
fetch("/api/file/createShareLink.php", { const container = document.getElementById("customExpirationContainer");
method: "POST", container.style.display = e.target.value === "custom" ? "block" : "none";
credentials: "include", });
headers: {
"Content-Type": "application/json", // Generate share link
"X-CSRF-Token": window.csrfToken document.getElementById("generateShareLinkBtn")
}, .addEventListener("click", () => {
body: JSON.stringify({ const sel = document.getElementById("shareExpiration");
folder: folder, let value, unit;
file: file.name,
expirationMinutes: parseInt(expiration), if (sel.value === "custom") {
password: password value = parseInt(document.getElementById("customExpirationValue").value, 10);
unit = document.getElementById("customExpirationUnit").value;
} else {
value = parseInt(sel.value, 10);
unit = "minutes";
}
const password = document.getElementById("sharePassword").value;
fetch("/api/file/createShareLink.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder,
file: file.name,
expirationValue: value,
expirationUnit: unit,
password
})
}) })
}) .then(res => res.json())
.then(response => response.json())
.then(data => { .then(data => {
if (data.token) { if (data.token) {
const shareEndpoint = `${window.location.origin}/api/file/share.php`; const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
const shareUrl = `${shareEndpoint}?token=${encodeURIComponent(data.token)}`; document.getElementById("shareLinkInput").value = url;
const displayDiv = document.getElementById("shareLinkDisplay"); document.getElementById("shareLinkDisplay").style.display = "block";
const inputField = document.getElementById("shareLinkInput");
inputField.value = shareUrl;
displayDiv.style.display = "block";
} else { } else {
showToast("Error generating share link: " + (data.error || "Unknown error")); showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
} }
}) })
.catch(err => { .catch(err => {
console.error("Error generating share link:", err); console.error(err);
showToast("Error generating share link."); showToast(t("error_generating_share"));
}); });
}); });
document.getElementById("copyShareLinkBtn").addEventListener("click", () => { // Copy to clipboard
const input = document.getElementById("shareLinkInput"); document.getElementById("copyShareLinkBtn")
input.select(); .addEventListener("click", () => {
document.execCommand("copy"); const input = document.getElementById("shareLinkInput");
showToast("Link copied to clipboard!"); input.select();
}); document.execCommand("copy");
showToast(t("link_copied"));
});
} }
export function previewFile(fileUrl, fileName) { export function previewFile(fileUrl, fileName) {
@@ -364,16 +416,21 @@ export function previewFile(fileUrl, fileName) {
} }
} else { } else {
// Handle non-image file previews. // Handle non-image file previews.
if (extension === "pdf") { if (extension === "pdf") {
const embed = document.createElement("embed"); // build a cachebusted URL
const separator = fileUrl.indexOf('?') === -1 ? '?' : '&'; const separator = fileUrl.includes('?') ? '&' : '?';
embed.src = fileUrl + separator + 't=' + new Date().getTime(); const urlWithTs = fileUrl + separator + 't=' + Date.now();
embed.type = "application/pdf";
embed.style.width = "80vw"; // open in a new tab (avoids CSP frame-ancestors)
embed.style.height = "80vh"; window.open(urlWithTs, "_blank");
embed.style.border = "none";
container.appendChild(embed); // tear down the just-created modal
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) { const modal = document.getElementById("filePreviewModal");
if (modal) modal.remove();
// stop further preview logic
return;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
const video = document.createElement("video"); const video = document.createElement("video");
video.src = fileUrl; video.src = fileUrl;
video.controls = true; video.controls = true;

View File

@@ -1,44 +1,75 @@
// folderShareModal.js // js/folderShareModal.js
import { escapeHTML, showToast } from './domUtils.js'; import { escapeHTML, showToast } from './domUtils.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
export function openFolderShareModal(folder) { export function openFolderShareModal(folder) {
// Remove any existing folder share modal // Remove any existing modal
const existing = document.getElementById("folderShareModal"); const existing = document.getElementById("folderShareModal");
if (existing) existing.remove(); if (existing) existing.remove();
// Create the modal container // Build modal
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.id = "folderShareModal"; modal.id = "folderShareModal";
modal.classList.add("modal"); modal.classList.add("modal");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content share-modal-content" style="width: 600px; max-width: 90vw;"> <div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
<div class="modal-header"> <div class="modal-header">
<h3>${t("share_folder")}: ${escapeHTML(folder)}</h3> <h3>${t("share_folder")}: ${escapeHTML(folder)}</h3>
<span class="close-image-modal" id="closeFolderShareModal" title="Close">&times;</span> <span id="closeFolderShareModal" title="${t("close")}" class="close-image-modal">&times;</span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>${t("set_expiration")}</p> <p>${t("set_expiration")}</p>
<select id="folderShareExpiration"> <select id="folderShareExpiration" style="width:100%;padding:5px;">
<option value="30">30 ${t("minutes")}</option> <option value="30">30 ${t("minutes")}</option>
<option value="60" selected>60 ${t("minutes")}</option> <option value="60" selected>60 ${t("minutes")}</option>
<option value="120">120 ${t("minutes")}</option> <option value="120">120 ${t("minutes")}</option>
<option value="180">180 ${t("minutes")}</option> <option value="180">180 ${t("minutes")}</option>
<option value="240">240 ${t("minutes")}</option> <option value="240">240 ${t("minutes")}</option>
<option value="1440">1 ${t("day")}</option> <option value="1440">1 ${t("day")}</option>
<option value="custom">${t("custom")}&hellip;</option>
</select> </select>
<p>${t("password_optional")}</p>
<input type="text" id="folderSharePassword" placeholder="${t("enter_password")}" style="width: 100%;"/> <div id="customFolderExpirationContainer" style="display:none;margin-top:10px;">
<br> <label for="customFolderExpirationValue">${t("duration")}:</label>
<label> <input type="number" id="customFolderExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
<input type="checkbox" id="folderShareAllowUpload"> ${t("allow_uploads")} <select id="customFolderExpirationUnit">
<option value="seconds">${t("seconds")}</option>
<option value="minutes" selected>${t("minutes")}</option>
<option value="hours">${t("hours")}</option>
<option value="days">${t("days")}</option>
</select>
<p class="share-warning" style="color:#a33;font-size:0.9em;margin-top:5px;">
${t("custom_duration_warning")}
</p>
</div>
<p style="margin-top:15px;">${t("password_optional")}</p>
<input
type="text"
id="folderSharePassword"
placeholder="${t("enter_password")}"
style="width:100%;padding:5px;"
/>
<label style="margin-top:10px;display:block;">
<input type="checkbox" id="folderShareAllowUpload" />
${t("allow_uploads")}
</label> </label>
<br><br>
<button id="generateFolderShareLinkBtn" class="btn btn-primary" style="margin-top: 10px;">${t("generate_share_link")}</button> <button
<div id="folderShareLinkDisplay" style="margin-top: 10px; display: none;"> id="generateFolderShareLinkBtn"
class="btn btn-primary"
style="margin-top:15px;"
>
${t("generate_share_link")}
</button>
<div id="folderShareLinkDisplay" style="margin-top:15px;display:none;">
<p>${t("shareable_link")}</p> <p>${t("shareable_link")}</p>
<input type="text" id="folderShareLinkInput" readonly style="width: 100%;"/> <input type="text" id="folderShareLinkInput" readonly style="width:100%;padding:5px;"/>
<button id="copyFolderShareLinkBtn" class="btn btn-primary" style="margin-top: 5px;">${t("copy_link")}</button> <button id="copyFolderShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
${t("copy_link")}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -46,62 +77,75 @@ export function openFolderShareModal(folder) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = "block"; modal.style.display = "block";
// Close button handler // Close
document.getElementById("closeFolderShareModal").addEventListener("click", () => { document.getElementById("closeFolderShareModal")
modal.remove(); .addEventListener("click", () => modal.remove());
});
// Handler for generating the share link // Toggle custom inputs
document.getElementById("generateFolderShareLinkBtn").addEventListener("click", () => { document.getElementById("folderShareExpiration")
const expiration = document.getElementById("folderShareExpiration").value; .addEventListener("change", e => {
const password = document.getElementById("folderSharePassword").value; document.getElementById("customFolderExpirationContainer")
const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0; .style.display = e.target.value === "custom" ? "block" : "none";
});
// Retrieve the CSRF token from the meta tag.
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content"); // Generate link
if (!csrfToken) { document.getElementById("generateFolderShareLinkBtn")
showToast(t("csrf_error")); .addEventListener("click", () => {
return; const sel = document.getElementById("folderShareExpiration");
} let value, unit;
// Post to the createFolderShareLink endpoint. if (sel.value === "custom") {
fetch("/api/folder/createShareFolderLink.php", { value = parseInt(document.getElementById("customFolderExpirationValue").value, 10);
method: "POST", unit = document.getElementById("customFolderExpirationUnit").value;
credentials: "include", } else {
headers: { value = parseInt(sel.value, 10);
"Content-Type": "application/json", unit = "minutes";
"X-CSRF-Token": csrfToken }
},
body: JSON.stringify({ const password = document.getElementById("folderSharePassword").value;
folder: folder, const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0;
expirationMinutes: parseInt(expiration, 10), const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
password: password, if (!csrfToken) {
allowUpload: allowUpload showToast(t("csrf_error"));
return;
}
fetch("/api/folder/createShareFolderLink.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
folder,
expirationValue: value,
expirationUnit: unit,
password,
allowUpload
})
}) })
}) .then(r => r.json())
.then(response => response.json())
.then(data => { .then(data => {
if (data.token && data.link) { if (data.token && data.link) {
const shareUrl = data.link; document.getElementById("folderShareLinkInput").value = data.link;
const displayDiv = document.getElementById("folderShareLinkDisplay"); document.getElementById("folderShareLinkDisplay").style.display = "block";
const inputField = document.getElementById("folderShareLinkInput");
inputField.value = shareUrl;
displayDiv.style.display = "block";
showToast(t("share_link_generated")); showToast(t("share_link_generated"));
} else { } else {
showToast(t("error_generating_share_link") + ": " + (data.error || t("unknown_error"))); showToast(t("error_generating_share_link") + ": " + (data.error||t("unknown_error")));
} }
}) })
.catch(err => { .catch(err => {
console.error("Error generating folder share link:", err); console.error(err);
showToast(t("error_generating_share_link") + ": " + (err.error || t("unknown_error"))); showToast(t("error_generating_share_link") + ": " + t("unknown_error"));
}); });
}); });
// Copy share link button handler // Copy
document.getElementById("copyFolderShareLinkBtn").addEventListener("click", () => { document.getElementById("copyFolderShareLinkBtn")
const input = document.getElementById("folderShareLinkInput"); .addEventListener("click", () => {
input.select(); const inp = document.getElementById("folderShareLinkInput");
document.execCommand("copy"); inp.select();
showToast(t("link_copied")); document.execCommand("copy");
}); showToast(t("link_copied"));
});
} }

View File

@@ -150,6 +150,13 @@ const translations = {
"allow_uploads": "Allow Uploads", "allow_uploads": "Allow Uploads",
"share_link_generated": "Share Link Generated", "share_link_generated": "Share Link Generated",
"error_generating_share_link": "Error Generating Share Link", "error_generating_share_link": "Error Generating Share Link",
"custom": "Custom",
"duration": "Duration",
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"custom_duration_warning": "⚠️ Using a long expiration may pose security risks. Use with caution.",
// Folder // Folder
"folder_share": "Share Folder", "folder_share": "Share Folder",
@@ -166,12 +173,8 @@ const translations = {
"user": "User:", "user": "User:",
"unknown_error": "Unknown Error", "unknown_error": "Unknown Error",
"link_copied": "Link Copied to Clipboard", "link_copied": "Link Copied to Clipboard",
"minutes": "minutes",
"hours": "hours",
"days": "days",
"weeks": "weeks", "weeks": "weeks",
"months": "months", "months": "months",
"seconds": "seconds",
// Dark Mode Toggle // Dark Mode Toggle
"dark_mode_toggle": "Dark Mode", "dark_mode_toggle": "Dark Mode",
@@ -239,7 +242,7 @@ const translations = {
"ok": "OK", "ok": "OK",
"show": "Show", "show": "Show",
"items_per_page": "items per page", "items_per_page": "items per page",
"columns":"Columns", "columns": "Columns",
"api_docs": "API Docs" "api_docs": "API Docs"
}, },
es: { es: {
@@ -806,7 +809,7 @@ const translations = {
"prev": "Zurück", "prev": "Zurück",
"next": "Weiter", "next": "Weiter",
"page": "Seite", "page": "Seite",
"of": "von", "of": "von",
// Login Form keys: // Login Form keys:
"login": "Anmelden", "login": "Anmelden",

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

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

View File

@@ -0,0 +1,90 @@
// sharedFolderView.js
document.addEventListener('DOMContentLoaded', function() {
let viewMode = 'list';
const payload = JSON.parse(
document.getElementById('shared-data').textContent
);
const token = payload.token;
const filesData = payload.files;
const downloadBase = `${window.location.origin}/api/folder/downloadSharedFile.php?token=${encodeURIComponent(token)}&file=`;
const btn = document.getElementById('toggleBtn');
if (btn) btn.classList.add('toggle-btn');
function toggleViewMode() {
const listEl = document.getElementById('listViewContainer');
const galleryEl = document.getElementById('galleryViewContainer');
if (viewMode === 'list') {
viewMode = 'gallery';
listEl.style.display = 'none';
renderGalleryView();
galleryEl.style.display = 'block';
btn.textContent = 'Switch to List View';
} else {
viewMode = 'list';
galleryEl.style.display = 'none';
listEl.style.display = 'block';
btn.textContent = 'Switch to Gallery View';
}
}
btn.addEventListener('click', toggleViewMode);
function renderGalleryView() {
const container = document.getElementById('galleryViewContainer');
// clear previous
while (container.firstChild) {
container.removeChild(container.firstChild);
}
const grid = document.createElement('div');
grid.className = 'shared-gallery-container';
filesData.forEach(file => {
const url = downloadBase + encodeURIComponent(file);
const ext = file.split('.').pop().toLowerCase();
const isImg = /^(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/.test(ext);
// card
const card = document.createElement('div');
card.className = 'shared-gallery-card';
// preview
const preview = document.createElement('div');
preview.className = 'gallery-preview';
preview.style.cursor = 'pointer';
preview.dataset.url = url;
if (isImg) {
const img = document.createElement('img');
img.src = url;
img.alt = file; // safe, file is not HTML
preview.appendChild(img);
} else {
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.textContent = 'insert_drive_file';
preview.appendChild(icon);
}
card.appendChild(preview);
// info
const info = document.createElement('div');
info.className = 'gallery-info';
const nameSpan = document.createElement('span');
nameSpan.className = 'gallery-file-name';
nameSpan.textContent = file; // textContent escapes any HTML
info.appendChild(nameSpan);
card.appendChild(info);
grid.appendChild(card);
preview.addEventListener('click', () => {
window.location.href = preview.dataset.url;
});
});
container.appendChild(grid);
}
window.renderGalleryView = renderGalleryView;
});

View File

@@ -4,7 +4,8 @@
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/FileModel.php'; require_once PROJECT_ROOT . '/src/models/FileModel.php';
class FileController { class FileController
{
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/copyFiles.php", * path="/api/file/copyFiles.php",
@@ -50,9 +51,10 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function copyFiles() { public function copyFiles()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// --- CSRF Protection --- // --- CSRF Protection ---
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -61,14 +63,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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 (assuming loadUserPermissions() is available). // Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
@@ -76,7 +78,7 @@ class FileController {
echo json_encode(["error" => "Read-only users are not allowed to copy files."]); echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
exit; exit;
} }
// Get JSON input data. // Get JSON input data.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if ( if (
@@ -89,11 +91,11 @@ class FileController {
echo json_encode(["error" => "Invalid request"]); echo json_encode(["error" => "Invalid request"]);
exit; exit;
} }
$sourceFolder = trim($data['source']); $sourceFolder = trim($data['source']);
$destinationFolder = trim($data['destination']); $destinationFolder = trim($data['destination']);
$files = $data['files']; $files = $data['files'];
// Validate folder names. // Validate folder names.
if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) { if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) {
echo json_encode(["error" => "Invalid source folder name."]); echo json_encode(["error" => "Invalid source folder name."]);
@@ -103,7 +105,7 @@ class FileController {
echo json_encode(["error" => "Invalid destination folder name."]); echo json_encode(["error" => "Invalid destination folder name."]);
exit; exit;
} }
// Delegate to the model. // Delegate to the model.
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
echo json_encode($result); echo json_encode($result);
@@ -153,9 +155,10 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function deleteFiles() { public function deleteFiles()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// --- CSRF Protection --- // --- CSRF Protection ---
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -164,14 +167,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Load user's permissions. // Load user's permissions.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
@@ -179,7 +182,7 @@ class FileController {
echo json_encode(["error" => "Read-only users are not allowed to delete files."]); echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
exit; exit;
} }
// Get JSON input. // Get JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!isset($data['files']) || !is_array($data['files'])) { if (!isset($data['files']) || !is_array($data['files'])) {
@@ -187,7 +190,7 @@ class FileController {
echo json_encode(["error" => "No file names provided"]); echo json_encode(["error" => "No file names provided"]);
exit; exit;
} }
// Determine folder; default to 'root'. // Determine folder; default to 'root'.
$folder = isset($data['folder']) ? trim($data['folder']) : 'root'; $folder = isset($data['folder']) ? trim($data['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
@@ -195,13 +198,13 @@ class FileController {
exit; exit;
} }
$folder = trim($folder, "/\\ "); $folder = trim($folder, "/\\ ");
// Delegate to the FileModel. // Delegate to the FileModel.
$result = FileModel::deleteFiles($folder, $data['files']); $result = FileModel::deleteFiles($folder, $data['files']);
echo json_encode($result); echo json_encode($result);
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/moveFiles.php", * path="/api/file/moveFiles.php",
* summary="Move files between folders", * summary="Move files between folders",
@@ -246,9 +249,10 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function moveFiles() { public function moveFiles()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// --- CSRF Protection --- // --- CSRF Protection ---
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -257,14 +261,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Verify that the user is not read-only. // Verify that the user is not read-only.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
@@ -272,7 +276,7 @@ class FileController {
echo json_encode(["error" => "Read-only users are not allowed to move files."]); echo json_encode(["error" => "Read-only users are not allowed to move files."]);
exit; exit;
} }
// Get JSON input. // Get JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if ( if (
@@ -285,10 +289,10 @@ class FileController {
echo json_encode(["error" => "Invalid request"]); echo json_encode(["error" => "Invalid request"]);
exit; exit;
} }
$sourceFolder = trim($data['source']) ?: 'root'; $sourceFolder = trim($data['source']) ?: 'root';
$destinationFolder = trim($data['destination']) ?: 'root'; $destinationFolder = trim($data['destination']) ?: 'root';
// Validate folder names. // Validate folder names.
if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) { if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) {
echo json_encode(["error" => "Invalid source folder name."]); echo json_encode(["error" => "Invalid source folder name."]);
@@ -298,13 +302,13 @@ class FileController {
echo json_encode(["error" => "Invalid destination folder name."]); echo json_encode(["error" => "Invalid destination folder name."]);
exit; exit;
} }
// Delegate to the model. // Delegate to the model.
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']); $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']);
echo json_encode($result); echo json_encode($result);
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/renameFile.php", * path="/api/file/renameFile.php",
* summary="Rename a file", * summary="Rename a file",
@@ -346,12 +350,13 @@ class FileController {
* *
* @return void Outputs a JSON response. * @return void Outputs a JSON response.
*/ */
public function renameFile() { public function renameFile()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
header("Cache-Control: no-cache, no-store, must-revalidate"); header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache"); header("Pragma: no-cache");
header("Expires: 0"); header("Expires: 0");
// --- CSRF Protection --- // --- CSRF Protection ---
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -360,14 +365,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Verify user permissions. // Verify user permissions.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
@@ -375,7 +380,7 @@ class FileController {
echo json_encode(["error" => "Read-only users are not allowed to rename files."]); echo json_encode(["error" => "Read-only users are not allowed to rename files."]);
exit; exit;
} }
// Get JSON input. // Get JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) { if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) {
@@ -383,29 +388,29 @@ class FileController {
echo json_encode(["error" => "Invalid input"]); echo json_encode(["error" => "Invalid input"]);
exit; exit;
} }
$folder = trim($data['folder']) ?: 'root'; $folder = trim($data['folder']) ?: 'root';
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes. // Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]); echo json_encode(["error" => "Invalid folder name"]);
exit; exit;
} }
$oldName = basename(trim($data['oldName'])); $oldName = basename(trim($data['oldName']));
$newName = basename(trim($data['newName'])); $newName = basename(trim($data['newName']));
// Validate file names. // Validate file names.
if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) { if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) {
echo json_encode(["error" => "Invalid file name."]); echo json_encode(["error" => "Invalid file name."]);
exit; exit;
} }
// Delegate the renaming operation to the model. // Delegate the renaming operation to the model.
$result = FileModel::renameFile($folder, $oldName, $newName); $result = FileModel::renameFile($folder, $oldName, $newName);
echo json_encode($result); echo json_encode($result);
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/saveFile.php", * path="/api/file/saveFile.php",
* summary="Save a file", * summary="Save a file",
@@ -446,9 +451,10 @@ class FileController {
* *
* @return void Outputs a JSON response. * @return void Outputs a JSON response.
*/ */
public function saveFile() { public function saveFile()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// --- CSRF Protection --- // --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? ''; $receivedToken = $headersArr['x-csrf-token'] ?? '';
@@ -457,14 +463,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// --- Authentication Check --- // --- Authentication Check ---
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { 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;
} }
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
// --- Readonly check --- // --- Readonly check ---
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
@@ -472,7 +478,7 @@ class FileController {
echo json_encode(["error" => "Read-only users are not allowed to save files."]); echo json_encode(["error" => "Read-only users are not allowed to save files."]);
exit; exit;
} }
// --- Input parsing --- // --- Input parsing ---
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (empty($data) || !isset($data["fileName"], $data["content"])) { if (empty($data) || !isset($data["fileName"], $data["content"])) {
@@ -480,17 +486,17 @@ class FileController {
echo json_encode(["error" => "Invalid request data", "received" => $data]); echo json_encode(["error" => "Invalid request data", "received" => $data]);
exit; exit;
} }
$fileName = basename($data["fileName"]); $fileName = basename($data["fileName"]);
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root"; $folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
// --- Folder validation --- // --- Folder validation ---
if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) { if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]); echo json_encode(["error" => "Invalid folder name"]);
exit; exit;
} }
$folder = trim($folder, "/\\ "); $folder = trim($folder, "/\\ ");
// --- Delegate to model, passing the uploader --- // --- Delegate to model, passing the uploader ---
// Make sure FileModel::saveFile signature is: // Make sure FileModel::saveFile signature is:
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null) // saveFile(string $folder, string $fileName, $content, ?string $uploader = null)
@@ -500,7 +506,7 @@ class FileController {
$data["content"], $data["content"],
$username // ← pass the real uploader here $username // ← pass the real uploader here
); );
echo json_encode($result); echo json_encode($result);
} }
@@ -555,7 +561,8 @@ class FileController {
* *
* @return void Outputs file content with appropriate headers. * @return void Outputs file content with appropriate headers.
*/ */
public function downloadFile() { public function downloadFile()
{
// Check if the user is authenticated. // Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
@@ -563,31 +570,31 @@ class FileController {
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
// Get GET parameters. // Get GET parameters.
$file = isset($_GET['file']) ? basename($_GET['file']) : ''; $file = isset($_GET['file']) ? basename($_GET['file']) : '';
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
// Validate the file name using REGEX_FILE_NAME. // Validate the file name using REGEX_FILE_NAME.
if (!preg_match(REGEX_FILE_NAME, $file)) { if (!preg_match(REGEX_FILE_NAME, $file)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid file name."]); echo json_encode(["error" => "Invalid file name."]);
exit; exit;
} }
// Retrieve download info from the model. // Retrieve download info from the model.
$downloadInfo = FileModel::getDownloadInfo($folder, $file); $downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) { if (isset($downloadInfo['error'])) {
http_response_code( (in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400 ); http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
echo json_encode(["error" => $downloadInfo['error']]); echo json_encode(["error" => $downloadInfo['error']]);
exit; exit;
} }
// Serve the file. // Serve the file.
$realFilePath = $downloadInfo['filePath']; $realFilePath = $downloadInfo['filePath'];
$mimeType = $downloadInfo['mimeType']; $mimeType = $downloadInfo['mimeType'];
header("Content-Type: " . $mimeType); header("Content-Type: " . $mimeType);
// For images, serve inline; for others, force download. // For images, serve inline; for others, force download.
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
$inlineImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']; $inlineImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'];
@@ -601,7 +608,7 @@ class FileController {
exit; exit;
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/downloadZip.php", * path="/api/file/downloadZip.php",
* summary="Download a ZIP archive of selected files", * summary="Download a ZIP archive of selected files",
@@ -649,7 +656,8 @@ class FileController {
* *
* @return void Outputs the ZIP file for download. * @return void Outputs the ZIP file for download.
*/ */
public function downloadZip() { public function downloadZip()
{
// --- CSRF Protection --- // --- CSRF Protection ---
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -659,7 +667,7 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
@@ -667,7 +675,7 @@ class FileController {
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
// Read and decode JSON input. // Read and decode JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) { if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
@@ -676,10 +684,10 @@ class FileController {
echo json_encode(["error" => "Invalid input."]); echo json_encode(["error" => "Invalid input."]);
exit; exit;
} }
$folder = $data['folder']; $folder = $data['folder'];
$files = $data['files']; $files = $data['files'];
// Validate folder: if not "root", split and validate each segment. // Validate folder: if not "root", split and validate each segment.
if ($folder !== "root") { if ($folder !== "root") {
$parts = explode('/', $folder); $parts = explode('/', $folder);
@@ -692,7 +700,7 @@ class FileController {
} }
} }
} }
// Create ZIP archive using FileModel. // Create ZIP archive using FileModel.
$result = FileModel::createZipArchive($folder, $files); $result = FileModel::createZipArchive($folder, $files);
if (isset($result['error'])) { if (isset($result['error'])) {
@@ -701,7 +709,7 @@ class FileController {
echo json_encode(["error" => $result['error']]); echo json_encode(["error" => $result['error']]);
exit; exit;
} }
$zipPath = $result['zipPath']; $zipPath = $result['zipPath'];
if (!file_exists($zipPath)) { if (!file_exists($zipPath)) {
http_response_code(500); http_response_code(500);
@@ -709,21 +717,21 @@ class FileController {
echo json_encode(["error" => "ZIP archive not found."]); echo json_encode(["error" => "ZIP archive not found."]);
exit; exit;
} }
// Send headers to force download. // Send headers to force download.
header('Content-Type: application/zip'); header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="files.zip"'); header('Content-Disposition: attachment; filename="files.zip"');
header('Content-Length: ' . filesize($zipPath)); header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: no-store, no-cache, must-revalidate'); header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache'); header('Pragma: no-cache');
// Output the ZIP file. // Output the ZIP file.
readfile($zipPath); readfile($zipPath);
unlink($zipPath); unlink($zipPath);
exit; exit;
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/extractZip.php", * path="/api/file/extractZip.php",
* summary="Extract ZIP files", * summary="Extract ZIP files",
@@ -768,9 +776,10 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function extractZip() { public function extractZip()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// --- CSRF Protection --- // --- CSRF Protection ---
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -779,14 +788,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Read and decode JSON input. // Read and decode JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) { if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
@@ -794,10 +803,10 @@ class FileController {
echo json_encode(["error" => "Invalid input."]); echo json_encode(["error" => "Invalid input."]);
exit; exit;
} }
$folder = $data['folder']; $folder = $data['folder'];
$files = $data['files']; $files = $data['files'];
// Validate folder name. // Validate folder name.
if ($folder !== "root") { if ($folder !== "root") {
$parts = explode('/', trim($folder)); $parts = explode('/', trim($folder));
@@ -809,13 +818,13 @@ class FileController {
} }
} }
} }
// Delegate to the model. // Delegate to the model.
$result = FileModel::extractZipArchive($folder, $files); $result = FileModel::extractZipArchive($folder, $files);
echo json_encode($result); echo json_encode($result);
} }
/** /**
* @OA\Get( * @OA\Get(
* path="/api/file/share.php", * path="/api/file/share.php",
* summary="Access a shared file", * summary="Access a shared file",
@@ -860,18 +869,19 @@ class FileController {
* *
* @return void Outputs either HTML (password form) or serves the file. * @return void Outputs either HTML (password form) or serves the file.
*/ */
public function shareFile() { public function shareFile()
{
// Retrieve and sanitize GET parameters. // Retrieve and sanitize GET parameters.
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
$providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
if (empty($token)) { if (empty($token)) {
http_response_code(400); http_response_code(400);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(["error" => "Missing token."]); echo json_encode(["error" => "Missing token."]);
exit; exit;
} }
// Get share record from the model. // Get share record from the model.
$record = FileModel::getShareRecord($token); $record = FileModel::getShareRecord($token);
if (!$record) { if (!$record) {
@@ -880,7 +890,7 @@ class FileController {
echo json_encode(["error" => "Share link not found."]); echo json_encode(["error" => "Share link not found."]);
exit; exit;
} }
// Check expiration. // Check expiration.
if (time() > $record['expires']) { if (time() > $record['expires']) {
http_response_code(403); http_response_code(403);
@@ -888,13 +898,14 @@ class FileController {
echo json_encode(["error" => "This link has expired."]); echo json_encode(["error" => "This link has expired."]);
exit; exit;
} }
// If a password is required and not provided, show an HTML form. // If a password is required and not provided, show an HTML form.
if (!empty($record['password']) && empty($providedPass)) { if (!empty($record['password']) && empty($providedPass)) {
header("Content-Type: text/html; charset=utf-8"); header("Content-Type: text/html; charset=utf-8");
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -906,14 +917,16 @@ class FileController {
background-color: #f4f4f4; background-color: #f4f4f4;
color: #333; color: #333;
} }
form { form {
max-width: 400px; max-width: 400px;
margin: 40px auto; margin: 40px auto;
background: #fff; background: #fff;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
input[type="password"] { input[type="password"] {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
@@ -921,6 +934,7 @@ class FileController {
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
} }
button { button {
padding: 10px 20px; padding: 10px 20px;
background: #007BFF; background: #007BFF;
@@ -929,11 +943,13 @@ class FileController {
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
} }
button:hover { button:hover {
background: #0056b3; background: #0056b3;
} }
</style> </style>
</head> </head>
<body> <body>
<h2>This file is protected by a password.</h2> <h2>This file is protected by a password.</h2>
<form method="get" action="/api/file/share.php"> <form method="get" action="/api/file/share.php">
@@ -943,11 +959,12 @@ class FileController {
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
</body> </body>
</html> </html>
<?php <?php
exit; exit;
} }
// If a password is required, validate the provided password. // If a password is required, validate the provided password.
if (!empty($record['password'])) { if (!empty($record['password'])) {
if (!password_verify($providedPass, $record['password'])) { if (!password_verify($providedPass, $record['password'])) {
@@ -957,7 +974,7 @@ class FileController {
exit; exit;
} }
} }
// Build file path securely. // Build file path securely.
$folder = trim($record['folder'], "/\\ "); $folder = trim($record['folder'], "/\\ ");
$file = $record['file']; $file = $record['file'];
@@ -966,7 +983,7 @@ class FileController {
$filePath .= $folder . DIRECTORY_SEPARATOR; $filePath .= $folder . DIRECTORY_SEPARATOR;
} }
$filePath .= $file; $filePath .= $file;
$realFilePath = realpath($filePath); $realFilePath = realpath($filePath);
$uploadDirReal = realpath(UPLOAD_DIR); $uploadDirReal = realpath(UPLOAD_DIR);
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) { if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
@@ -981,12 +998,12 @@ class FileController {
echo json_encode(["error" => "File not found."]); echo json_encode(["error" => "File not found."]);
exit; exit;
} }
// Serve the file. // Serve the file.
$mimeType = mime_content_type($realFilePath); $mimeType = mime_content_type($realFilePath);
header("Content-Type: " . $mimeType); header("Content-Type: " . $mimeType);
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) { if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) {
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
} else { } else {
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
@@ -994,25 +1011,31 @@ class FileController {
header("Cache-Control: no-store, no-cache, must-revalidate"); header("Cache-Control: no-store, no-cache, must-revalidate");
header("Pragma: no-cache"); header("Pragma: no-cache");
header('Content-Length: ' . filesize($realFilePath)); header('Content-Length: ' . filesize($realFilePath));
readfile($realFilePath); readfile($realFilePath);
exit; exit;
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/createShareLink.php", * path="/api/file/createShareLink.php",
* summary="Create a share link for a file", * summary="Create a share link for a file",
* description="Generates a secure share link token for a specific file with an optional password protection and expiration time.", * description="Generates a secure share link token for a specific file with optional password protection and a custom expiration time.",
* operationId="createShareLink", * operationId="createShareLink",
* tags={"Files"}, * tags={"Files"},
* @OA\RequestBody( * @OA\RequestBody(
* required=true, * required=true,
* @OA\JsonContent( * @OA\JsonContent(
* required={"folder", "file"}, * required={"folder", "file", "expirationValue", "expirationUnit"},
* @OA\Property(property="folder", type="string", example="Documents"), * @OA\Property(property="folder", type="string", example="Documents"),
* @OA\Property(property="file", type="string", example="report.pdf"), * @OA\Property(property="file", type="string", example="report.pdf"),
* @OA\Property(property="expirationMinutes", type="integer", example=60), * @OA\Property(property="expirationValue", type="integer", example=1),
* @OA\Property(
* property="expirationUnit",
* type="string",
* enum={"seconds","minutes","hours","days"},
* example="minutes"
* ),
* @OA\Property(property="password", type="string", example="secret") * @OA\Property(property="password", type="string", example="secret")
* ) * )
* ), * ),
@@ -1042,25 +1065,26 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function createShareLink() { public function createShareLink()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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. // Check user permissions.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { if ($username && !empty($userPermissions['readOnly'])) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Read-only users are not allowed to create share links."]); echo json_encode(["error" => "Read-only users are not allowed to create share links."]);
exit; exit;
} }
// Parse POST JSON input. // Parse POST JSON input.
$input = json_decode(file_get_contents("php://input"), true); $input = json_decode(file_get_contents("php://input"), true);
if (!$input) { if (!$input) {
@@ -1068,26 +1092,45 @@ class FileController {
echo json_encode(["error" => "Invalid input."]); echo json_encode(["error" => "Invalid input."]);
exit; exit;
} }
// Extract parameters. // Extract parameters.
$folder = isset($input['folder']) ? trim($input['folder']) : ""; $folder = isset($input['folder']) ? trim($input['folder']) : "";
$file = isset($input['file']) ? basename($input['file']) : ""; $file = isset($input['file']) ? basename($input['file']) : "";
$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60; $value = isset($input['expirationValue']) ? intval($input['expirationValue']) : 60;
$unit = isset($input['expirationUnit']) ? $input['expirationUnit'] : 'minutes';
$password = isset($input['password']) ? $input['password'] : ""; $password = isset($input['password']) ? $input['password'] : "";
// Validate folder. // Validate folder name.
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]); echo json_encode(["error" => "Invalid folder name."]);
exit; exit;
} }
// Convert the provided value+unit into seconds
switch ($unit) {
case 'seconds':
$expirationSeconds = $value;
break;
case 'hours':
$expirationSeconds = $value * 3600;
break;
case 'days':
$expirationSeconds = $value * 86400;
break;
case 'minutes':
default:
$expirationSeconds = $value * 60;
break;
}
// Delegate share link creation to the model. // Delegate share link creation to the model.
$result = FileModel::createShareLink($folder, $file, $expirationMinutes, $password); $result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password);
echo json_encode($result); echo json_encode($result);
} }
/** /**
* @OA\Get( * @OA\Get(
* path="/api/file/getTrashItems.php", * path="/api/file/getTrashItems.php",
* summary="Get trash items", * summary="Get trash items",
@@ -1109,16 +1152,17 @@ class FileController {
* *
* @return void Outputs JSON response with trash items. * @return void Outputs JSON response with trash items.
*/ */
public function getTrashItems() { public function getTrashItems()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Delegate to the model. // Delegate to the model.
$trashItems = FileModel::getTrashItems(); $trashItems = FileModel::getTrashItems();
echo json_encode($trashItems); echo json_encode($trashItems);
@@ -1164,9 +1208,10 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function restoreFiles() { public function restoreFiles()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// CSRF Protection. // CSRF Protection.
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -1175,14 +1220,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Read POST input. // Read POST input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!isset($data['files']) || !is_array($data['files'])) { if (!isset($data['files']) || !is_array($data['files'])) {
@@ -1190,13 +1235,13 @@ class FileController {
echo json_encode(["error" => "No file or folder identifiers provided"]); echo json_encode(["error" => "No file or folder identifiers provided"]);
exit; exit;
} }
// Delegate restoration to the model. // Delegate restoration to the model.
$result = FileModel::restoreFiles($data['files']); $result = FileModel::restoreFiles($data['files']);
echo json_encode($result); echo json_encode($result);
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/deleteTrashFiles.php", * path="/api/file/deleteTrashFiles.php",
* summary="Delete trash files", * summary="Delete trash files",
@@ -1247,9 +1292,10 @@ class FileController {
* *
* @return void Outputs a JSON response. * @return void Outputs a JSON response.
*/ */
public function deleteTrashFiles() { public function deleteTrashFiles()
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// CSRF Protection. // CSRF Protection.
$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 = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
@@ -1258,14 +1304,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Read and decode JSON input. // Read and decode JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!$data) { if (!$data) {
@@ -1273,7 +1319,7 @@ class FileController {
echo json_encode(["error" => "Invalid input"]); echo json_encode(["error" => "Invalid input"]);
exit; exit;
} }
// Determine deletion mode. // Determine deletion mode.
$filesToDelete = []; $filesToDelete = [];
if (isset($data['deleteAll']) && $data['deleteAll'] === true) { if (isset($data['deleteAll']) && $data['deleteAll'] === true) {
@@ -1299,14 +1345,14 @@ class FileController {
echo json_encode(["error" => "No trash file identifiers provided"]); echo json_encode(["error" => "No trash file identifiers provided"]);
exit; exit;
} }
// Delegate deletion to the model. // Delegate deletion to the model.
$result = FileModel::deleteTrashFiles($filesToDelete); $result = FileModel::deleteTrashFiles($filesToDelete);
// Build a humanfriendly success or error message // Build a humanfriendly success or error message
if (!empty($result['deleted'])) { if (!empty($result['deleted'])) {
$count = count($result['deleted']); $count = count($result['deleted']);
$msg = "Trash item" . ($count===1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']); $msg = "Trash item" . ($count === 1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']);
echo json_encode(["success" => $msg]); echo json_encode(["success" => $msg]);
} elseif (!empty($result['error'])) { } elseif (!empty($result['error'])) {
echo json_encode(["error" => $result['error']]); echo json_encode(["error" => $result['error']]);
@@ -1316,7 +1362,7 @@ class FileController {
exit; exit;
} }
/** /**
* @OA\Get( * @OA\Get(
* path="/api/file/getFileTag.php", * path="/api/file/getFileTag.php",
* summary="Retrieve file tags", * summary="Retrieve file tags",
@@ -1337,15 +1383,16 @@ class FileController {
* *
* @return void Outputs JSON response with file tags. * @return void Outputs JSON response with file tags.
*/ */
public function getFileTags(): void { public function getFileTags(): void
{
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
$tags = FileModel::getFileTags(); $tags = FileModel::getFileTags();
echo json_encode($tags); echo json_encode($tags);
exit; exit;
} }
/** /**
* @OA\Post( * @OA\Post(
* path="/api/file/saveFileTag.php", * path="/api/file/saveFileTag.php",
* summary="Save file tags", * summary="Save file tags",
@@ -1397,7 +1444,8 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function saveFileTag(): void { public function saveFileTag(): void
{
header("Cache-Control: no-cache, no-store, must-revalidate"); header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache"); header("Pragma: no-cache");
header("Expires: 0"); header("Expires: 0");
@@ -1411,14 +1459,14 @@ class FileController {
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(["error" => "Invalid CSRF token"]);
exit; exit;
} }
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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 that the user is not read-only. // Check that the user is not read-only.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = loadUserPermissions($username);
@@ -1426,7 +1474,7 @@ class FileController {
echo json_encode(["error" => "Read-only users are not allowed to file tags"]); echo json_encode(["error" => "Read-only users are not allowed to file tags"]);
exit; exit;
} }
// Retrieve and sanitize input. // Retrieve and sanitize input.
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!$data) { if (!$data) {
@@ -1434,26 +1482,26 @@ class FileController {
echo json_encode(["error" => "No data received"]); echo json_encode(["error" => "No data received"]);
exit; exit;
} }
$file = isset($data['file']) ? trim($data['file']) : ''; $file = isset($data['file']) ? trim($data['file']) : '';
$folder = isset($data['folder']) ? trim($data['folder']) : 'root'; $folder = isset($data['folder']) ? trim($data['folder']) : 'root';
$tags = $data['tags'] ?? []; $tags = $data['tags'] ?? [];
$deleteGlobal = isset($data['deleteGlobal']) ? (bool)$data['deleteGlobal'] : false; $deleteGlobal = isset($data['deleteGlobal']) ? (bool)$data['deleteGlobal'] : false;
$tagToDelete = isset($data['tagToDelete']) ? trim($data['tagToDelete']) : null; $tagToDelete = isset($data['tagToDelete']) ? trim($data['tagToDelete']) : null;
if ($file === '') { if ($file === '') {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "No file specified."]); echo json_encode(["error" => "No file specified."]);
exit; exit;
} }
// Validate folder name. // Validate folder name.
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]); echo json_encode(["error" => "Invalid folder name."]);
exit; exit;
} }
// Delegate to the model. // Delegate to the model.
$result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete);
echo json_encode($result); echo json_encode($result);
@@ -1496,16 +1544,17 @@ class FileController {
* *
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function getFileList(): void { public function getFileList(): void
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// Ensure user is authenticated. // Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_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;
} }
// Retrieve the folder from GET; default to "root". // Retrieve the folder from GET; default to "root".
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
@@ -1513,7 +1562,7 @@ class FileController {
echo json_encode(["error" => "Invalid folder name."]); echo json_encode(["error" => "Invalid folder name."]);
exit; exit;
} }
// Delegate to the model. // Delegate to the model.
$result = FileModel::getFileList($folder); $result = FileModel::getFileList($folder);
if (isset($result['error'])) { if (isset($result['error'])) {
@@ -1522,4 +1571,4 @@ class FileController {
echo json_encode($result); echo json_encode($result);
exit; exit;
} }
} }

View File

@@ -550,13 +550,18 @@ class FolderController
body { body {
background: #f2f2f2; background: #f2f2f2;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
padding: 20px; padding: 0px 20px 20px 20px;
color: #333; color: #333;
} }
.header { .header {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
margin-top: 0;
}
.header h1 {
margin-top: 0;
} }
.container { .container {
@@ -661,6 +666,28 @@ class FolderController
font-size: 0.9rem; font-size: 0.9rem;
color: #777; color: #777;
} }
.toggle-btn {
background-color: #007BFF;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 1rem;
cursor: pointer;
}
.toggle-btn:hover {
background-color: #0056b3;
}
.pagination a:hover {
background-color: #0056b3;
}
.pagination span {
cursor: default;
}
</style> </style>
</head> </head>
@@ -670,7 +697,7 @@ class FolderController
</div> </div>
<div class="container"> <div class="container">
<!-- Toggle Button --> <!-- Toggle Button -->
<button id="toggleBtn" class="toggle-btn" onclick="toggleViewMode()">Switch to Gallery View</button> <button id="toggleBtn" class="toggle-btn">Switch to Gallery View</button>
<!-- List View Container --> <!-- List View Container -->
<div id="listViewContainer"> <div id="listViewContainer">
@@ -757,86 +784,14 @@ class FolderController
<div class="footer"> <div class="footer">
&copy; <?php echo date("Y"); ?> FileRise. All rights reserved. &copy; <?php echo date("Y"); ?> FileRise. All rights reserved.
</div> </div>
<!-- non-executing JSON payload, never blocked by CSP -->
<script> <script type="application/json" id="shared-data">
document.addEventListener('DOMContentLoaded', function() { {
// JavaScript for toggling view modes (list/gallery) and wiring up gallery clicks "token": <?php echo json_encode($token, JSON_HEX_TAG); ?>,
var viewMode = 'list'; "files": <?php echo json_encode($files, JSON_HEX_TAG); ?>
var token = '<?php echo addslashes($token); ?>'; }
var filesData = <?php echo json_encode($files); ?>;
// Build the download URL base
var downloadBase = window.location.origin +
'/api/folder/downloadSharedFile.php?token=' +
encodeURIComponent(token) +
'&file=';
function toggleViewMode() {
var listEl = document.getElementById('listViewContainer');
var galleryEl = document.getElementById('galleryViewContainer');
var btn = document.getElementById('toggleBtn');
if (viewMode === 'list') {
viewMode = 'gallery';
listEl.style.display = 'none';
renderGalleryView();
galleryEl.style.display = 'block';
btn.textContent = 'Switch to List View';
} else {
viewMode = 'list';
galleryEl.style.display = 'none';
listEl.style.display = 'block';
btn.textContent = 'Switch to Gallery View';
}
}
// Wire up the toggle button
document.getElementById('toggleBtn')
.addEventListener('click', toggleViewMode);
function renderGalleryView() {
var galleryContainer = document.getElementById('galleryViewContainer');
var html = '<div class="shared-gallery-container">';
filesData.forEach(function(file) {
var encodedName = encodeURIComponent(file);
var fileUrl = downloadBase + encodedName;
var ext = file.split('.').pop().toLowerCase();
var thumb;
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].indexOf(ext) >= 0) {
thumb = '<img src="' + fileUrl + '" alt="' + file + '">';
} else {
thumb = '<span class="material-icons">insert_drive_file</span>';
}
html +=
'<div class="shared-gallery-card">' +
'<div class="gallery-preview" data-url="' + fileUrl + '" style="cursor:pointer;">' +
thumb +
'</div>' +
'<div class="gallery-info">' +
'<span class="gallery-file-name">' + file + '</span>' +
'</div>' +
'</div>';
});
html += '</div>';
galleryContainer.innerHTML = html;
// Wire up each thumbnail click
galleryContainer.querySelectorAll('.gallery-preview')
.forEach(function(el) {
el.addEventListener('click', function() {
window.location.href = el.dataset.url;
});
});
}
// Expose for manual invocation if needed
window.renderGalleryView = renderGalleryView;
});
</script> </script>
<script src="/js/sharedFolderView.js" defer></script>
</body> </body>
</html> </html>
@@ -892,38 +847,63 @@ class FolderController
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
// Ensure user is authenticated. // Auth check
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { 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 that the user is not read-only. // Read-only check
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $perms = loadUserPermissions($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { if ($username && !empty($perms['readOnly'])) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Read-only users are not allowed to create share folders."]); echo json_encode(["error" => "Read-only users are not allowed to create share folders."]);
exit; exit;
} }
// Retrieve and decode POST input. // Input
$input = json_decode(file_get_contents("php://input"), true); $in = json_decode(file_get_contents("php://input"), true);
if (!$input || !isset($input['folder'])) { if (!$in || !isset($in['folder'])) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid input."]); echo json_encode(["error" => "Invalid input."]);
exit; exit;
} }
$folder = trim($input['folder']); $folder = trim($in['folder']);
$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60; $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60;
$password = isset($input['password']) ? $input['password'] : ""; $unit = $in['expirationUnit'] ?? 'minutes';
$allowUpload = isset($input['allowUpload']) ? intval($input['allowUpload']) : 0; $password = $in['password'] ?? '';
$allowUpload = intval($in['allowUpload'] ?? 0);
// Delegate to the model. // Folder name validation
$result = FolderModel::createShareFolderLink($folder, $expirationMinutes, $password, $allowUpload); if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode($result); http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
// Convert to seconds
switch ($unit) {
case 'seconds':
$seconds = $value;
break;
case 'hours':
$seconds = $value * 3600;
break;
case 'days':
$seconds = $value * 86400;
break;
case 'minutes':
default:
$seconds = $value * 60;
break;
}
// Delegate
$res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload);
echo json_encode($res);
exit; exit;
} }

View File

@@ -736,7 +736,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
* @return array Returns an associative array with keys "token" and "expires" on success, * @return array Returns an associative array with keys "token" and "expires" on success,
* or "error" on failure. * or "error" on failure.
*/ */
public static function createShareLink($folder, $file, $expirationMinutes = 60, $password = "") { public static function createShareLink($folder, $file, $expirationSeconds = 3600, $password = "") {
// Validate folder if necessary (this can also be done in the controller). // Validate folder if necessary (this can also be done in the controller).
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."]; return ["error" => "Invalid folder name."];
@@ -746,7 +746,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
$token = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(16));
// Calculate expiration (Unix timestamp). // Calculate expiration (Unix timestamp).
$expires = time() + ($expirationMinutes * 60); $expires = time() + $expirationSeconds;
// Hash the password if provided. // Hash the password if provided.
$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : ""; $hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : "";

View File

@@ -3,7 +3,8 @@
require_once PROJECT_ROOT . '/config/config.php'; require_once PROJECT_ROOT . '/config/config.php';
class FolderModel { class FolderModel
{
/** /**
* Creates a folder under the specified parent (or in root) and creates an empty metadata file. * Creates a folder under the specified parent (or in root) and creates an empty metadata file.
* *
@@ -12,10 +13,11 @@ class FolderModel {
* @return array Returns an array with a "success" key if the folder was created, * @return array Returns an array with a "success" key if the folder was created,
* or an "error" key if an error occurred. * or an "error" key if an error occurred.
*/ */
public static function createFolder(string $folderName, string $parent = ""): array { public static function createFolder(string $folderName, string $parent = ""): array
{
$folderName = trim($folderName); $folderName = trim($folderName);
$parent = trim($parent); $parent = trim($parent);
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed). // Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed).
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
return ["error" => "Invalid folder name."]; return ["error" => "Invalid folder name."];
@@ -23,7 +25,7 @@ class FolderModel {
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) { if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
return ["error" => "Invalid parent folder name."]; return ["error" => "Invalid parent folder name."];
} }
$baseDir = rtrim(UPLOAD_DIR, '/\\'); $baseDir = rtrim(UPLOAD_DIR, '/\\');
if ($parent !== "" && strtolower($parent) !== "root") { if ($parent !== "" && strtolower($parent) !== "root") {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName; $fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
@@ -32,12 +34,12 @@ class FolderModel {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName; $fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $folderName; $relativePath = $folderName;
} }
// Check if the folder already exists. // Check if the folder already exists.
if (file_exists($fullPath)) { if (file_exists($fullPath)) {
return ["error" => "Folder already exists."]; return ["error" => "Folder already exists."];
} }
// Attempt to create the folder. // Attempt to create the folder.
if (mkdir($fullPath, 0755, true)) { if (mkdir($fullPath, 0755, true)) {
// Create an empty metadata file for the new folder. // Create an empty metadata file for the new folder.
@@ -50,52 +52,54 @@ class FolderModel {
return ["error" => "Failed to create folder."]; return ["error" => "Failed to create folder."];
} }
} }
/** /**
* Generates the metadata file path for a given folder. * Generates the metadata file path for a given folder.
* *
* @param string $folder The relative folder path. * @param string $folder The relative folder path.
* @return string The metadata file path. * @return string The metadata file path.
*/ */
private static function getMetadataFilePath(string $folder): string { private static function getMetadataFilePath(string $folder): string
{
if (strtolower($folder) === 'root' || trim($folder) === '') { if (strtolower($folder) === 'root' || trim($folder) === '') {
return META_DIR . "root_metadata.json"; return META_DIR . "root_metadata.json";
} }
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
} }
/** /**
* Deletes a folder if it is empty and removes its corresponding metadata. * Deletes a folder if it is empty and removes its corresponding metadata.
* *
* @param string $folder The folder name (relative to the upload directory). * @param string $folder The folder name (relative to the upload directory).
* @return array An associative array with "success" on success or "error" on failure. * @return array An associative array with "success" on success or "error" on failure.
*/ */
public static function deleteFolder(string $folder): array { public static function deleteFolder(string $folder): array
{
// Prevent deletion of "root". // Prevent deletion of "root".
if (strtolower($folder) === 'root') { if (strtolower($folder) === 'root') {
return ["error" => "Cannot delete root folder."]; return ["error" => "Cannot delete root folder."];
} }
// Validate folder name. // Validate folder name.
if (!preg_match(REGEX_FOLDER_NAME, $folder)) { if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."]; return ["error" => "Invalid folder name."];
} }
// Build the full folder path. // Build the full folder path.
$baseDir = rtrim(UPLOAD_DIR, '/\\'); $baseDir = rtrim(UPLOAD_DIR, '/\\');
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder; $folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
// Check if the folder exists and is a directory. // Check if the folder exists and is a directory.
if (!file_exists($folderPath) || !is_dir($folderPath)) { if (!file_exists($folderPath) || !is_dir($folderPath)) {
return ["error" => "Folder does not exist."]; return ["error" => "Folder does not exist."];
} }
// Prevent deletion if the folder is not empty. // Prevent deletion if the folder is not empty.
$items = array_diff(scandir($folderPath), array('.', '..')); $items = array_diff(scandir($folderPath), array('.', '..'));
if (count($items) > 0) { if (count($items) > 0) {
return ["error" => "Folder is not empty."]; return ["error" => "Folder is not empty."];
} }
// Attempt to delete the folder. // Attempt to delete the folder.
if (rmdir($folderPath)) { if (rmdir($folderPath)) {
// Remove corresponding metadata file. // Remove corresponding metadata file.
@@ -109,43 +113,45 @@ class FolderModel {
} }
} }
/** /**
* Renames a folder and updates related metadata files. * Renames a folder and updates related metadata files.
* *
* @param string $oldFolder The current folder name (relative to UPLOAD_DIR). * @param string $oldFolder The current folder name (relative to UPLOAD_DIR).
* @param string $newFolder The new folder name. * @param string $newFolder The new folder name.
* @return array Returns an associative array with "success" on success or "error" on failure. * @return array Returns an associative array with "success" on success or "error" on failure.
*/ */
public static function renameFolder(string $oldFolder, string $newFolder): array { public static function renameFolder(string $oldFolder, string $newFolder): array
{
// Sanitize and trim folder names. // Sanitize and trim folder names.
$oldFolder = trim($oldFolder, "/\\ "); $oldFolder = trim($oldFolder, "/\\ ");
$newFolder = trim($newFolder, "/\\ "); $newFolder = trim($newFolder, "/\\ ");
// Validate folder names. // Validate folder names.
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
return ["error" => "Invalid folder name(s)."]; return ["error" => "Invalid folder name(s)."];
} }
// Build the full folder paths. // Build the full folder paths.
$baseDir = rtrim(UPLOAD_DIR, '/\\'); $baseDir = rtrim(UPLOAD_DIR, '/\\');
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder; $oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder; $newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
// Validate that the old folder exists and new folder does not. // Validate that the old folder exists and new folder does not.
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) || if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
strpos(realpath($oldPath), realpath($baseDir)) !== 0 || strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0) { strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0
) {
return ["error" => "Invalid folder path."]; return ["error" => "Invalid folder path."];
} }
if (!file_exists($oldPath) || !is_dir($oldPath)) { if (!file_exists($oldPath) || !is_dir($oldPath)) {
return ["error" => "Folder to rename does not exist."]; return ["error" => "Folder to rename does not exist."];
} }
if (file_exists($newPath)) { if (file_exists($newPath)) {
return ["error" => "New folder name already exists."]; return ["error" => "New folder name already exists."];
} }
// Attempt to rename the folder. // Attempt to rename the folder.
if (rename($oldPath, $newPath)) { if (rename($oldPath, $newPath)) {
// Update metadata: Rename all metadata files that have the old folder prefix. // Update metadata: Rename all metadata files that have the old folder prefix.
@@ -171,7 +177,8 @@ class FolderModel {
* @param string $relative The relative path from the base directory. * @param string $relative The relative path from the base directory.
* @return array An array of folder paths (relative to the base). * @return array An array of folder paths (relative to the base).
*/ */
private static function getSubfolders(string $dir, string $relative = ''): array { private static function getSubfolders(string $dir, string $relative = ''): array
{
$folders = []; $folders = [];
$items = scandir($dir); $items = scandir($dir);
$safeFolderNamePattern = REGEX_FOLDER_NAME; $safeFolderNamePattern = REGEX_FOLDER_NAME;
@@ -198,7 +205,8 @@ class FolderModel {
* *
* @return array An array of folder information arrays. * @return array An array of folder information arrays.
*/ */
public static function getFolderList(): array { public static function getFolderList(): array
{
$baseDir = rtrim(UPLOAD_DIR, '/\\'); $baseDir = rtrim(UPLOAD_DIR, '/\\');
$folderInfoList = []; $folderInfoList = [];
@@ -240,13 +248,14 @@ class FolderModel {
return $folderInfoList; return $folderInfoList;
} }
/** /**
* Retrieves the share folder record for a given token. * Retrieves the share folder record for a given token.
* *
* @param string $token The share folder token. * @param string $token The share folder token.
* @return array|null The share folder record, or null if not found. * @return array|null The share folder record, or null if not found.
*/ */
public static function getShareFolderRecord(string $token): ?array { public static function getShareFolderRecord(string $token): ?array
{
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) {
return null; return null;
@@ -257,8 +266,8 @@ class FolderModel {
} }
return $shareLinks[$token]; return $shareLinks[$token];
} }
/** /**
* Retrieves shared folder data based on a share token. * Retrieves shared folder data based on a share token.
* *
* @param string $token The share folder token. * @param string $token The share folder token.
@@ -274,7 +283,8 @@ class FolderModel {
* - 'totalPages': total pages, * - 'totalPages': total pages,
* or an 'error' key on failure. * or an 'error' key on failure.
*/ */
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array { public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array
{
// Load the share folder record. // Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) {
@@ -314,7 +324,7 @@ class FolderModel {
return ["error" => "Shared folder not found."]; return ["error" => "Shared folder not found."];
} }
// Scan for files (only files). // Scan for files (only files).
$allFiles = array_values(array_filter(scandir($realFolderPath), function($item) use ($realFolderPath) { $allFiles = array_values(array_filter(scandir($realFolderPath), function ($item) use ($realFolderPath) {
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item); return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item);
})); }));
sort($allFiles); sort($allFiles);
@@ -323,7 +333,7 @@ class FolderModel {
$currentPage = min($page, $totalPages); $currentPage = min($page, $totalPages);
$startIndex = ($currentPage - 1) * $itemsPerPage; $startIndex = ($currentPage - 1) * $itemsPerPage;
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage); $filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
return [ return [
"record" => $record, "record" => $record,
"folder" => $folder, "folder" => $folder,
@@ -334,81 +344,72 @@ class FolderModel {
]; ];
} }
/** /**
* Creates a share link for a folder. * Creates a share link for a folder.
* *
* @param string $folder The folder to share (relative to UPLOAD_DIR). * @param string $folder The folder to share (relative to UPLOAD_DIR).
* @param int $expirationMinutes The duration (in minutes) until the link expires. * @param int $expirationSeconds How many seconds until expiry.
* @param string $password Optional password for the share. * @param string $password Optional password.
* @param int $allowUpload Optional flag (0 or 1) indicating whether uploads are allowed. * @param int $allowUpload 0 or 1 whether uploads are allowed.
* @return array An associative array with "token", "expires", and "link" on success, or "error" on failure. * @return array ["token","expires","link"] on success, or ["error"].
*/ */
public static function createShareFolderLink(string $folder, int $expirationMinutes = 60, string $password = "", int $allowUpload = 0): array { public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
// Validate folder name. {
// Validate folder
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."]; return ["error" => "Invalid folder name."];
} }
// Generate secure token. // Token
try { try {
$token = bin2hex(random_bytes(16)); // 32 hex characters. $token = bin2hex(random_bytes(16));
} catch (Exception $e) { } catch (Exception $e) {
return ["error" => "Could not generate token."]; return ["error" => "Could not generate token."];
} }
// Calculate expiration time. // Expiry
$expires = time() + ($expirationMinutes * 60); $expires = time() + $expirationSeconds;
// Hash the password if provided. // Password hash
$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : ""; $hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
// Define the share folder links file. // Load existing
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
$shareLinks = []; $links = file_exists($shareFile)
if (file_exists($shareFile)) { ? json_decode(file_get_contents($shareFile), true) ?? []
$data = file_get_contents($shareFile); : [];
$shareLinks = json_decode($data, true);
if (!is_array($shareLinks)) { // Cleanup
$shareLinks = []; $now = time();
foreach ($links as $k => $v) {
if (!empty($v['expires']) && $v['expires'] < $now) {
unset($links[$k]);
} }
} }
// Clean up expired share links. // Add new
$currentTime = time(); $links[$token] = [
foreach ($shareLinks as $key => $link) { "folder" => $folder,
if (isset($link["expires"]) && $link["expires"] < $currentTime) { "expires" => $expires,
unset($shareLinks[$key]); "password" => $hashedPassword,
}
}
// Add new share record.
$shareLinks[$token] = [
"folder" => $folder,
"expires" => $expires,
"password" => $hashedPassword,
"allowUpload" => $allowUpload "allowUpload" => $allowUpload
]; ];
// Save the updated share links. // Save
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT)) === false) { if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Could not save share link."]; return ["error" => "Could not save share link."];
} }
// Determine the base URL. // Build URL
if (defined('BASE_URL') && !empty(BASE_URL) && strpos(BASE_URL, 'yourwebsite') === false) { $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
$baseUrl = rtrim(BASE_URL, '/'); $host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
} else { $baseUrl = $protocol . '://' . rtrim($host, '/');
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http"; $link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
$host = !empty($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : gethostbyname($_SERVER['SERVER_ADDR'] ?? 'localhost');
$baseUrl = $protocol . "://" . $host;
}
// The share URL points to the shared folder page.
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
return ["token" => $token, "expires" => $expires, "link" => $link]; return ["token" => $token, "expires" => $expires, "link" => $link];
} }
/** /**
* Retrieves information for a shared file from a shared folder link. * Retrieves information for a shared file from a shared folder link.
* *
* @param string $token The share folder token. * @param string $token The share folder token.
@@ -418,7 +419,8 @@ class FolderModel {
* - "realFilePath": the absolute path to the file, * - "realFilePath": the absolute path to the file,
* - "mimeType": the detected MIME type. * - "mimeType": the detected MIME type.
*/ */
public static function getSharedFileInfo(string $token, string $file): array { public static function getSharedFileInfo(string $token, string $file): array
{
// Load the share folder record. // Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) {
@@ -457,14 +459,14 @@ class FolderModel {
return ["error" => "Invalid file name."]; return ["error" => "Invalid file name."];
} }
$file = basename($file); $file = basename($file);
// Build the full file path. // Build the full file path.
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file; $filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
$realFilePath = realpath($filePath); $realFilePath = realpath($filePath);
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) { if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
return ["error" => "File not found."]; return ["error" => "File not found."];
} }
$mimeType = mime_content_type($realFilePath); $mimeType = mime_content_type($realFilePath);
return [ return [
"realFilePath" => $realFilePath, "realFilePath" => $realFilePath,
@@ -479,11 +481,12 @@ class FolderModel {
* @param array $fileUpload The $_FILES['fileToUpload'] array. * @param array $fileUpload The $_FILES['fileToUpload'] array.
* @return array An associative array with "success" on success or "error" on failure. * @return array An associative array with "success" on success or "error" on failure.
*/ */
public static function uploadToSharedFolder(string $token, array $fileUpload): array { public static function uploadToSharedFolder(string $token, array $fileUpload): array
{
// Define maximum file size and allowed extensions. // Define maximum file size and allowed extensions.
$maxSize = 50 * 1024 * 1024; // 50 MB $maxSize = 50 * 1024 * 1024; // 50 MB
$allowedExtensions = ['jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx','mp4','webm','mp3','mkv']; $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv'];
// Load the share folder record. // Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) {
@@ -494,55 +497,55 @@ class FolderModel {
return ["error" => "Invalid share token."]; return ["error" => "Invalid share token."];
} }
$record = $shareLinks[$token]; $record = $shareLinks[$token];
// Check expiration. // Check expiration.
if (time() > $record['expires']) { if (time() > $record['expires']) {
return ["error" => "This share link has expired."]; return ["error" => "This share link has expired."];
} }
// Check whether uploads are allowed. // Check whether uploads are allowed.
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) { if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
return ["error" => "File uploads are not allowed for this share."]; return ["error" => "File uploads are not allowed for this share."];
} }
// Validate file upload presence. // Validate file upload presence.
if ($fileUpload['error'] !== UPLOAD_ERR_OK) { if ($fileUpload['error'] !== UPLOAD_ERR_OK) {
return ["error" => "File upload error. Code: " . $fileUpload['error']]; return ["error" => "File upload error. Code: " . $fileUpload['error']];
} }
if ($fileUpload['size'] > $maxSize) { if ($fileUpload['size'] > $maxSize) {
return ["error" => "File size exceeds allowed limit."]; return ["error" => "File size exceeds allowed limit."];
} }
$uploadedName = basename($fileUpload['name']); $uploadedName = basename($fileUpload['name']);
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions)) { if (!in_array($ext, $allowedExtensions)) {
return ["error" => "File type not allowed."]; return ["error" => "File type not allowed."];
} }
// Determine the target folder from the share record. // Determine the target folder from the share record.
$folderName = trim($record['folder'], "/\\"); $folderName = trim($record['folder'], "/\\");
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; $targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($folderName) && strtolower($folderName) !== 'root') { if (!empty($folderName) && strtolower($folderName) !== 'root') {
$targetFolder .= $folderName; $targetFolder .= $folderName;
} }
// Verify target folder exists. // Verify target folder exists.
$realTargetFolder = realpath($targetFolder); $realTargetFolder = realpath($targetFolder);
$uploadDirReal = realpath(UPLOAD_DIR); $uploadDirReal = realpath(UPLOAD_DIR);
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) { if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) {
return ["error" => "Shared folder not found."]; return ["error" => "Shared folder not found."];
} }
// Generate a new filename (using uniqid and sanitizing the original name). // Generate a new filename (using uniqid and sanitizing the original name).
$newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName); $newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename; $targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename;
// Move the uploaded file. // Move the uploaded file.
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) { if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
return ["error" => "Failed to move the uploaded file."]; return ["error" => "Failed to move the uploaded file."];
} }
// --- Metadata Update --- // --- Metadata Update ---
// Determine metadata file. // Determine metadata file.
$metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName; $metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName;
@@ -564,7 +567,7 @@ class FolderModel {
"uploader" => $uploader "uploader" => $uploader
]; ];
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT)); file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename]; return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
} }
} }