release(v3.5.1): authenticated Link File deep links + shared file request mode

- files(core): add internal authenticated file links (create/resolve token endpoints + ACL-checked deep-link open)
- ui(files): add Link File in row context menu + toolbar single-selection action with copy-link modal
- boot/auth: preserve fileLink return URL through login/OIDC, resolve link post-auth, focus/highlight linked file
- shares: add upload-only File Request mode for shared folders with hide-listing support and dedicated drop-zone UI
- shared-upload: add per-link validation/rate limits/quota tracking + safer relative-path handling for chunked uploads
- admin: improve shared links listing with file-request grouping and created-by/source metadata
This commit is contained in:
Ryan
2026-02-19 00:47:53 -05:00
committed by GitHub
parent c009f31b28
commit 75f2d6de69
25 changed files with 3441 additions and 349 deletions
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## Changes 02/19/2026 (v3.5.1)
`release(v3.5.1): authenticated Link File deep links + shared file request mode`
**Commit message**
```text
release(v3.5.1): authenticated Link File deep links + shared file request mode
- files(core): add internal authenticated file links (create/resolve token endpoints + ACL-checked deep-link open)
- ui(files): add Link File in row context menu + toolbar single-selection action with copy-link modal
- boot/auth: preserve fileLink return URL through login/OIDC, resolve link post-auth, focus/highlight linked file
- shares: add upload-only File Request mode for shared folders with hide-listing support and dedicated drop-zone UI
- shared-upload: add per-link validation/rate limits/quota tracking + safer relative-path handling for chunked uploads
- admin: improve shared links listing with file-request grouping and created-by/source metadata
```
**Added**
- **Authenticated internal file deep links (Core)**
- Added new API endpoints:
- `/api/file/createAuthFileLink.php`
- `/api/file/resolveAuthFileLink.php`
- Added internal token persistence/lookup for file links in `FileModel`.
- Added new frontend link module:
- `public/js/fileLink.js`
- **Shared folder file-request experience**
- Added upload-only (“drop”) mode support with hide-listing behavior.
- Added dedicated upload UI for drop mode:
- `public/js/sharedDropView.js`
- Added share options for upload constraints:
- max file size
- allowed file types
- daily file-count limit
- daily total-size limit
- preserve-folder-structure toggle
**Changed**
- **File actions/UI**
- Added `Link File` action to:
- file row context menu
- file toolbar (enabled only for exactly one selected file).
- **Auth/deep-link boot flow**
- `main.js` now preserves `?fileLink=` return URL through login/OIDC and resolves it after auth.
- Linked file navigation now opens the target folder and selects/highlights the file row.
- **Folder share modal + public share page**
- Share modal now supports both classic folder share and upload-only file request creation.
- Public shared-folder view switches between browse mode and drop mode assets/layout.
- **Admin shared-link management**
- Shared links list now distinguishes folder shares vs file requests and surfaces source/creator context.
---
## Changes 02/16/2026 (v3.5.0)
`release(v3.5.0): async transfer jobs + Transfer Center UX, service-layer API refactors, and folder UI consistency fixes`
+2
View File
@@ -38,6 +38,8 @@ Built for homelabs, teams, and client portals that need fast browsing, strict AC
- 💾 **Self-hosted “cloud drive”** Runs anywhere with PHP (or via Docker). No external database required.
- 🔐 **Granular per-folder ACLs** Manage View (all/own), Upload, Create, Edit, Rename, Move, Copy, Delete, Extract, Share, and more — all enforced consistently across the UI, API, and WebDAV.
- 🔗 **Link File (authenticated deep links)** Generate internal links to specific files, require login + ACL checks, and open directly to the target in the app.
- 📥 **File Request links (upload-only)** Share upload-only links so external users can submit files into a folder without browsing existing files.
- 🔐 **Folder-level encryption at rest (optional)** Encrypt entire folders (and all descendants) on disk using modern authenticated encryption.
- Opt-in per folder with inherited protection for subfolders
- Files are stored encrypted on disk and transparently decrypted on download
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
$fileController = new \FileRise\Http\Controllers\FileController();
$fileController->createAuthFileLink();
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
$fileController = new \FileRise\Http\Controllers\FileController();
$fileController->resolveAuthFileLink();
+9 -1
View File
@@ -19,7 +19,15 @@
* @OA\Property(property="expirationUnit", type="string", enum={"seconds","minutes","hours","days"}, example="minutes"),
* @OA\Property(property="password", type="string", example=""),
* @OA\Property(property="allowUpload", type="integer", enum={0,1}, example=0),
* @OA\Property(property="allowSubfolders", type="integer", enum={0,1}, example=0)
* @OA\Property(property="allowSubfolders", type="integer", enum={0,1}, example=0),
* @OA\Property(property="mode", type="string", enum={"browse","drop"}, example="drop"),
* @OA\Property(property="fileDrop", type="integer", enum={0,1}, example=1),
* @OA\Property(property="hideListing", type="integer", enum={0,1}, example=1),
* @OA\Property(property="preserveFolderStructure", type="integer", enum={0,1}, example=1),
* @OA\Property(property="maxFileSizeMb", type="integer", example=100),
* @OA\Property(property="allowedTypes", type="array", @OA\Items(type="string", example="pdf")),
* @OA\Property(property="dailyFileLimit", type="integer", example=250),
* @OA\Property(property="maxTotalMbPerDay", type="integer", example=2048)
* )
* ),
* @OA\Response(
+8 -1
View File
@@ -19,7 +19,14 @@
* @OA\Property(property="token", type="string", description="Share token"),
* @OA\Property(property="pass", type="string", description="Share password (if required)"),
* @OA\Property(property="path", type="string", description="Optional subfolder path within the shared folder"),
* @OA\Property(property="fileToUpload", type="string", format="binary", description="File to upload")
* @OA\Property(property="relativePath", type="string", description="Optional relative path for folder uploads"),
* @OA\Property(property="fileToUpload", type="string", format="binary", description="File to upload"),
* @OA\Property(property="resumableChunkNumber", type="integer", description="Chunk number for chunked upload"),
* @OA\Property(property="resumableTotalChunks", type="integer", description="Total chunks"),
* @OA\Property(property="resumableIdentifier", type="string", description="Chunk upload identifier"),
* @OA\Property(property="resumableFilename", type="string", description="Original file name"),
* @OA\Property(property="resumableRelativePath", type="string", description="Original relative path"),
* @OA\Property(property="file", type="string", format="binary", description="Chunk payload when chunked")
* )
* )
* }
+144
View File
@@ -443,6 +443,12 @@
margin-bottom: 10px;
}
.fr-share-upload-subtitle {
color: var(--share-muted);
font-size: 0.88rem;
margin: -4px 0 12px;
}
.fr-share-upload-form {
display: flex;
gap: 10px;
@@ -454,6 +460,133 @@
flex: 1;
}
.fr-share-upload-form-drop {
display: block;
}
.fr-share-dropzone {
border: 1px dashed var(--share-border);
border-radius: 14px;
padding: 22px 14px;
text-align: center;
background: rgba(15, 23, 42, 0.02);
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.fr-share-dropzone.is-dragover {
border-color: var(--share-accent);
background: rgba(37, 99, 235, 0.08);
transform: translateY(-1px);
}
.fr-share-dropzone-title {
font-weight: 600;
font-size: 1rem;
}
.fr-share-dropzone-subtitle {
font-size: 0.86rem;
color: var(--share-muted);
margin-top: 4px;
}
.fr-share-dropzone-actions {
margin-top: 14px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.fr-share-drop-rules {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fr-share-rule-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--share-border);
border-radius: 999px;
padding: 4px 10px;
font-size: 0.76rem;
color: var(--share-muted);
background: rgba(15, 23, 42, 0.02);
}
.fr-share-drop-queue {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.fr-share-queue-item {
border: 1px solid var(--share-border);
border-radius: 12px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.02);
}
.fr-share-queue-item.is-done {
border-color: rgba(16, 185, 129, 0.45);
}
.fr-share-queue-item.is-error {
border-color: rgba(220, 38, 38, 0.45);
}
.fr-share-queue-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.fr-share-queue-name {
font-size: 0.86rem;
font-weight: 500;
overflow-wrap: anywhere;
}
.fr-share-queue-status {
font-size: 0.75rem;
color: var(--share-muted);
white-space: nowrap;
}
.fr-share-queue-meta {
font-size: 0.72rem;
color: var(--share-muted);
margin-top: 4px;
}
.fr-share-queue-progress {
margin-top: 8px;
height: 7px;
border-radius: 999px;
background: var(--share-border);
overflow: hidden;
}
.fr-share-queue-progress-fill {
width: 0%;
height: 100%;
background: var(--share-accent);
transition: width 0.12s linear;
}
.fr-share-queue-item.is-error .fr-share-queue-progress-fill {
background: #dc2626;
}
.fr-share-queue-item.is-done .fr-share-queue-progress-fill {
background: #059669;
}
.fr-share-upload-progress {
margin-top: 10px;
display: flex;
@@ -543,6 +676,10 @@
color: #b91c1c;
}
.fr-share-upload-error {
margin-top: 10px;
}
.fr-share-breadcrumbs {
display: flex;
align-items: center;
@@ -597,6 +734,13 @@
.fr-share-actions {
width: 100%;
}
.fr-share-dropzone-actions {
justify-content: stretch;
}
.fr-share-dropzone-actions .fr-share-btn {
flex: 1;
justify-content: center;
}
.fr-share-preview iframe,
.fr-share-preview img,
.fr-share-preview video {
+79
View File
@@ -1714,6 +1714,85 @@ label{font-size: 0.9rem;}
transform: scale(1.05);}
.share-modal-content{width: 600px !important;
max-width: 90vw !important;}
.share-modal-body{display:flex;
flex-direction:column;
gap:12px;}
.share-modal-helper{margin:0;
color:#666;
font-size:.92em;}
.share-modal-section{border:1px solid #d9d9d9;
border-radius:10px;
padding:12px;
background:#fafafa;}
.share-section-title{margin:0 0 8px;
font-weight:600;
font-size:.96rem;}
.share-mode-toggle{display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:8px;}
.share-mode-btn{border:1px solid #cfcfcf;
background:#fff;
border-radius:10px;
padding:10px;
text-align:left;
cursor:pointer;
transition:border-color .15s ease,box-shadow .15s ease,background-color .15s ease;}
.share-mode-btn:hover{border-color:#0b5ed7;}
.share-mode-btn.is-active{border-color:#0b5ed7;
box-shadow:0 0 0 2px rgba(11,94,215,.15);
background:#f3f8ff;}
.share-mode-btn-title{display:block;
font-weight:600;
font-size:.93rem;
color:#111;}
.share-mode-btn-desc{display:block;
margin-top:4px;
font-size:.82rem;
color:#555;
line-height:1.3;}
.share-mode-notice{margin:8px 0 0;
font-size:.86rem;
color:#555;}
.share-field-label{display:block;
margin:0 0 6px;
font-size:.88rem;
font-weight:600;}
.share-field-input{width:100%;
padding:6px;}
.share-custom-expiration{margin-top:10px;
display:flex;
flex-wrap:wrap;
align-items:center;
gap:8px;}
.share-custom-expiration input[type="number"]{width:72px;}
.share-custom-expiration .share-warning{margin:0;
width:100%;
color:#a33;
font-size:.86rem;}
.share-check{display:flex;
align-items:flex-start;
gap:8px;
margin:0;}
.share-check + .share-check{margin-top:8px;}
.share-check-helper{margin:6px 0 0 25px;
font-size:.84rem;
color:#666;}
@media (max-width: 680px) {
.share-mode-toggle{grid-template-columns:1fr;}
}
.dark-mode .share-modal-helper{color:#b8b8b8;}
.dark-mode .share-modal-section{background:#262626;
border-color:#404040;}
.dark-mode .share-mode-btn{background:#1f1f1f;
border-color:#505050;}
.dark-mode .share-mode-btn:hover{border-color:#7ab3ff;}
.dark-mode .share-mode-btn.is-active{border-color:#7ab3ff;
box-shadow:0 0 0 2px rgba(122,179,255,.22);
background:#223042;}
.dark-mode .share-mode-btn-title{color:#ececec;}
.dark-mode .share-mode-btn-desc,
.dark-mode .share-mode-notice,
.dark-mode .share-check-helper{color:#b9b9b9;}
.dark-mode .close-image-modal{background-color: rgba(0, 0, 0, 0.6);
color: #ff6666;}
.dark-mode .close-image-modal:hover{background-color: #ff6666;
+9
View File
@@ -339,6 +339,9 @@
<button id="renameSelectedBtn" class="btn action-btn icon-only" disabled title="Rename">
<span class="material-icons" aria-hidden="true">drive_file_rename_outline</span>
</button>
<button id="linkSelectedBtn" class="btn action-btn icon-only" disabled title="Link file" data-i18n-title="link_file">
<span class="material-icons" aria-hidden="true">link</span>
</button>
<button id="shareSelectedBtn" class="btn action-btn icon-only" disabled title="Share">
<span class="material-icons" aria-hidden="true">share</span>
</button>
@@ -639,6 +642,12 @@
<i class="material-icons">sell</i>
<span>Tag file</span>
</button>
<button type="button" class="mi"
data-action="link_file"
data-when="one">
<i class="material-icons">link</i>
<span>Link file</span>
</button>
<button type="button" class="mi"
data-action="share_file"
data-when="one">
+91 -17
View File
@@ -4980,25 +4980,82 @@ function loadShareLinksSection() {
])
.then(([folders, files]) => {
const esc = (val) => escapeHTML(val == null ? "" : String(val));
const hasAny = Object.keys(folders).length || Object.keys(files).length;
const asMap = (obj) => (obj && typeof obj === "object" && !Array.isArray(obj)) ? obj : {};
const folderMap = asMap(folders);
const fileMap = asMap(files);
const truthy = (v) => {
if (v === true || v === 1) return true;
const s = String(v ?? "").trim().toLowerCase();
return s === "1" || s === "true" || s === "yes" || s === "on";
};
const isFileRequest = (record) => {
if (!record || typeof record !== "object") return false;
const mode = String(record.mode || "").toLowerCase();
return mode === "drop" || truthy(record.fileDrop) || truthy(record.fileDropMode) || truthy(record.hideListing);
};
const creatorOf = (record) => {
if (!record || typeof record !== "object") return "";
const candidates = [record.createdBy, record.created_by, record.startedBy, record.user, record.username];
for (const val of candidates) {
if (typeof val === "string" && val.trim() !== "") {
return val.trim();
}
}
return "";
};
const sourceHtmlFor = (record) => {
const sourceLabel = record?.sourceName || record?.sourceId || "";
if (!sourceLabel || sourceLabel.toLowerCase() === "local") {
return "";
}
return ` <small class="text-muted">[${esc(sourceLabel)}]</small>`;
};
const creatorHtmlFor = (record) => {
const creator = creatorOf(record);
if (!creator) {
return "";
}
return ` <small class="text-muted">${esc(t("shared_created_by", { user: creator }))}</small>`;
};
const expiryHtmlFor = (record) => {
const expires = Number(record?.expires || 0);
if (!Number.isFinite(expires) || expires <= 0) {
return "";
}
return ` <small>(${esc(new Date(expires * 1000).toLocaleString())})</small>`;
};
const folderEntries = Object.entries(folderMap).filter(([, o]) => o && typeof o === "object");
const fileEntries = Object.entries(fileMap).filter(([, o]) => o && typeof o === "object");
const folderShareEntries = [];
const fileRequestEntries = [];
folderEntries.forEach((entry) => {
if (isFileRequest(entry[1])) {
fileRequestEntries.push(entry);
} else {
folderShareEntries.push(entry);
}
});
const hasAny = folderShareEntries.length || fileRequestEntries.length || fileEntries.length;
if (!hasAny) {
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
return;
}
let html = `<h5>${t("folder_shares")}</h5><ul>`;
Object.entries(folders).forEach(([token, o]) => {
const emptyItem = `<li><small class="text-muted">${esc(t("none"))}</small></li>`;
let html = `<h5>${esc(t("folder_shares"))}</h5><ul>`;
if (folderShareEntries.length === 0) {
html += emptyItem;
}
folderShareEntries.forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
const tokenValue = o.token || token;
const sourceLabel = o.sourceName || o.sourceId || "";
const sourceHtml = sourceLabel && sourceLabel.toLowerCase() !== "local"
? ` <small class="text-muted">[${esc(sourceLabel)}]</small>`
: "";
const folderLabel = esc(o.folder || "root");
html += `
<li>
${lock}<strong>${folderLabel}</strong>${sourceHtml}
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
${lock}<strong>${folderLabel}</strong>${sourceHtmlFor(o)}${creatorHtmlFor(o)}${expiryHtmlFor(o)}
<button type="button"
data-key="${esc(tokenValue)}"
data-source-id="${esc(o.sourceId || "")}"
@@ -5007,21 +5064,38 @@ function loadShareLinksSection() {
</li>`;
});
html += `</ul><h5 style="margin-top:1em;">${t("file_shares")}</h5><ul>`;
Object.entries(files).forEach(([token, o]) => {
html += `</ul><h5 style="margin-top:1em;">${esc(t("file_requests"))}</h5><ul>`;
if (fileRequestEntries.length === 0) {
html += emptyItem;
}
fileRequestEntries.forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
const tokenValue = o.token || token;
const folderLabel = esc(o.folder || "root");
html += `
<li>
${lock}<strong>${folderLabel}</strong>${sourceHtmlFor(o)}${creatorHtmlFor(o)}${expiryHtmlFor(o)}
<button type="button"
data-key="${esc(tokenValue)}"
data-source-id="${esc(o.sourceId || "")}"
data-type="folder"
class="btn btn-sm btn-link delete-share">🗑</button>
</li>`;
});
html += `</ul><h5 style="margin-top:1em;">${esc(t("file_shares"))}</h5><ul>`;
if (fileEntries.length === 0) {
html += emptyItem;
}
fileEntries.forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
const tokenValue = o.token || token;
const sourceLabel = o.sourceName || o.sourceId || "";
const sourceHtml = sourceLabel && sourceLabel.toLowerCase() !== "local"
? ` <small class="text-muted">[${esc(sourceLabel)}]</small>`
: "";
const folderLabel = esc(o.folder || "root");
const fileLabel = esc(o.file || "");
const pathLabel = fileLabel ? `${folderLabel}/${fileLabel}` : folderLabel;
html += `
<li>
${lock}<strong>${pathLabel}</strong>${sourceHtml}
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
${lock}<strong>${pathLabel}</strong>${sourceHtmlFor(o)}${creatorHtmlFor(o)}${expiryHtmlFor(o)}
<button type="button"
data-key="${esc(tokenValue)}"
data-source-id="${esc(o.sourceId || "")}"
+2 -1
View File
@@ -169,9 +169,10 @@ export function initializeApp() {
loadHeaderOrder();
initFileActions();
initUpload();
initSourceSelector().finally(() => {
const sourceInitPromise = initSourceSelector().finally(() => {
loadFolderTree();
});
window.__frSourceInitPromise = sourceInitPromise;
// Only run trash/restore for admins
const isAdmin =
+4
View File
@@ -67,6 +67,7 @@ export function updateFileActionButtons() {
const extractZipBtn = document.getElementById("extractZipBtn");
const createBtn = document.getElementById("createBtn");
const renameBtn = document.getElementById("renameSelectedBtn");
const linkBtn = document.getElementById("linkSelectedBtn");
const shareBtn = document.getElementById("shareSelectedBtn");
const folderActionsInline = document.getElementById("folderActionsInline");
const folderMoveBtn = document.getElementById("folderMoveInlineBtn");
@@ -96,6 +97,7 @@ export function updateFileActionButtons() {
const allowMove = currentFolderCaps ? !!(currentFolderCaps.canMoveIn || currentFolderCaps.canMove) : true;
const allowRename = currentFolderCaps ? !!(currentFolderCaps.canRename || currentFolderCaps.isAdmin) : true;
const allowDelete = currentFolderCaps ? !!currentFolderCaps.canDelete : true;
const allowLink = currentFolderCaps ? !!(currentFolderCaps.canView || currentFolderCaps.canViewOwn) : true;
const allowShare = currentFolderCaps ? !!(currentFolderCaps.canShareFile || currentFolderCaps.canShare) : true;
const allowExtract = currentFolderCaps ? !!currentFolderCaps.canExtract : true;
const inEncryptedFolder = !!(currentFolderCaps && currentFolderCaps.encryption && currentFolderCaps.encryption.encrypted);
@@ -183,6 +185,7 @@ export function updateFileActionButtons() {
if (moveBtn) moveBtn.style.display = showFileActions ? "" : "none";
if (zipBtn) zipBtn.style.display = showFileActions ? "" : "none";
if (renameBtn) renameBtn.style.display = showFileActions ? "" : "none";
if (linkBtn) linkBtn.style.display = showFileActions ? "" : "none";
if (shareBtn) shareBtn.style.display = (showFileActions && !inEncryptedFolder) ? "" : "none";
if (createBtn) createBtn.style.display = "";
@@ -196,6 +199,7 @@ export function updateFileActionButtons() {
setEnabled(moveBtn, showFileActions && anySelected && allowMove);
setEnabled(zipBtn, showFileActions && anySelected && allowDownload);
setEnabled(renameBtn, showFileActions && singleSelected && allowRename);
setEnabled(linkBtn, showFileActions && singleSelected && allowLink);
setEnabled(shareBtn, showFileActions && singleSelected && allowShare && !inEncryptedFolder);
setEnabled(extractZipBtn, showFileActions && anyZip && allowExtract && !inEncryptedFolder);
+23
View File
@@ -1311,6 +1311,24 @@ export function handleShareSelected(e) {
.catch(err => console.error("Failed to open share modal", err));
}
export function handleLinkSelected(e) {
if (e) {
e.preventDefault();
e.stopImmediatePropagation();
}
const files = getSelectedFileObjects();
if (files.length !== 1) {
showToast(t("select_single_file") || "Select one file to link.", 'warning');
return;
}
const fileObj = files[0];
const folder = fileObj.folder || window.currentFolder || "root";
import('./fileLink.js?v={{APP_QVER}}')
.then(mod => mod.openFileLinkModal(fileObj, folder))
.catch(err => console.error("Failed to open file link modal", err));
}
export async function handleToolbarMenuOpen(e) {
if (e) {
e.preventDefault();
@@ -1451,6 +1469,11 @@ export function initFileActions() {
shareSelectedBtn.replaceWith(shareSelectedBtn.cloneNode(true));
document.getElementById("shareSelectedBtn").addEventListener("click", handleShareSelected);
}
const linkSelectedBtn = document.getElementById("linkSelectedBtn");
if (linkSelectedBtn) {
linkSelectedBtn.replaceWith(linkSelectedBtn.cloneNode(true));
document.getElementById("linkSelectedBtn").addEventListener("click", handleLinkSelected);
}
const toolbarMenuBtn = document.getElementById("toolbarMenuBtn");
if (toolbarMenuBtn) {
toolbarMenuBtn.replaceWith(toolbarMenuBtn.cloneNode(true));
+128
View File
@@ -0,0 +1,128 @@
import { t } from './i18n.js?v={{APP_QVER}}';
import { withBase } from './basePath.js?v={{APP_QVER}}';
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
async function copyTextToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
} finally {
ta.remove();
}
return ok;
}
function buildInternalLink(token) {
return `${window.location.origin}${withBase(`/index.html?fileLink=${encodeURIComponent(token)}`)}`;
}
function openFileLinkResultModal(fileName, link) {
const existing = document.getElementById('fileLinkResultModal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'fileLinkResultModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content share-modal-content" style="max-width:560px;">
<div class="modal-header">
<h3>${t('link_file')}: ${escapeHTML(fileName)}</h3>
<span id="closeFileLinkResultModalX" title="${t('close')}" class="close-image-modal">&times;</span>
</div>
<div class="modal-body">
<p style="margin-bottom:6px;">${t('shareable_link')}</p>
<input id="fileLinkResultInput" type="text" readonly style="width:100%;padding:6px;" />
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button id="copyFileLinkResultBtn" class="btn btn-primary">${t('copy_link')}</button>
<button id="closeFileLinkResultBtn" class="btn btn-secondary">${t('close')}</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
const inputEl = document.getElementById('fileLinkResultInput');
if (inputEl) {
inputEl.value = link;
inputEl.focus();
inputEl.select();
}
const close = () => modal.remove();
const closeX = document.getElementById('closeFileLinkResultModalX');
const closeBtn = document.getElementById('closeFileLinkResultBtn');
const copyBtn = document.getElementById('copyFileLinkResultBtn');
if (closeX) closeX.addEventListener('click', close);
if (closeBtn) closeBtn.addEventListener('click', close);
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
if (copyBtn && inputEl) {
copyBtn.addEventListener('click', async () => {
const ok = await copyTextToClipboard(inputEl.value);
showToast(ok ? t('link_copied') : t('unknown_error'));
});
}
}
async function createAuthFileLink(folder, file, sourceId = '') {
const resp = await fetch(withBase('/api/file/createAuthFileLink.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || '',
'Accept': 'application/json',
},
body: JSON.stringify({
folder,
file,
sourceId: String(sourceId || '').trim(),
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data || data.ok !== true) {
throw new Error((data && (data.error || data.message)) || `HTTP ${resp.status}`);
}
return data;
}
export async function openFileLinkModal(fileObj, folder) {
const fileName = String(fileObj?.name || '').trim();
if (!fileName) {
showToast(t('unknown_error'), 'error');
return;
}
const targetFolder = String(folder || fileObj?.folder || window.currentFolder || 'root');
const sourceId = String(fileObj?.sourceId || (window.__frGetActiveSourceId ? window.__frGetActiveSourceId() : '') || '').trim();
try {
const data = await createAuthFileLink(targetFolder, fileName, sourceId);
const token = String(data.token || '').trim();
if (!token) {
throw new Error(t('file_link_create_failed'));
}
const link = buildInternalLink(token);
openFileLinkResultModal(fileName, link);
} catch (e) {
const msg = (e && e.message) ? e.message : t('file_link_create_failed');
showToast(t('file_link_create_failed_detail', { error: msg }), 'error');
}
}
+4
View File
@@ -5540,6 +5540,10 @@ async function navigateToSearchResult(folder, name, sourceId) {
setTimeout(() => maybeHighlightSearchedFile(dest), 500);
}
export async function navigateToLinkedFile(folder, name, sourceId) {
await navigateToSearchResult(folder, name, sourceId);
}
function bindFolderToolbarActions() {
const map = [
{ id: "folderMoveInlineBtn", handler: (folder) => openMoveFolderUI(folder) },
+11 -1
View File
@@ -7,6 +7,7 @@ import {
renameFile, openCreateFileModal
} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl, openShareModal } from './filePreview.js?v={{APP_QVER}}';
import { openFileLinkModal } from './fileLink.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData, downloadSelectedFilesIndividually, startInlineRenameFromContext } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
@@ -32,6 +33,7 @@ function localizeMenu() {
'tag_file': 'tag_file',
// NEW:
'download_plain': 'download_plain',
'link_file': 'link_file',
'share_file': 'share_file'
};
Object.entries(map).forEach(([action, key]) => {
@@ -41,7 +43,7 @@ function localizeMenu() {
}
// Show/hide items based on selection state
function configureVisibility({ any, one, many, anyZip, canEdit, allowShare, allowZip, allowExtractZip }) {
function configureVisibility({ any, one, many, anyZip, canEdit, allowLink, allowShare, allowZip, allowExtractZip }) {
const m = qMenu(); if (!m) return;
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
@@ -55,6 +57,8 @@ function configureVisibility({ any, one, many, anyZip, canEdit, allowShare, allo
// Capability gates (e.g. encrypted folders disable share/zip actions)
try {
const link = m.querySelector('.mi[data-action="link_file"]');
if (link) link.hidden = link.hidden || !allowLink;
const share = m.querySelector('.mi[data-action="share_file"]');
if (share) share.hidden = share.hidden || !allowShare;
const dlZip = m.querySelector('.mi[data-action="download_zip"]');
@@ -141,6 +145,7 @@ function currentSelection() {
const caps = window.currentFolderCaps || null;
const inEncrypted = !!(caps && caps.encryption && caps.encryption.encrypted);
const allowShare = !inEncrypted && (caps ? !!(caps.canShareFile || caps.canShare) : true);
const allowLink = caps ? !!(caps.canView || caps.canViewOwn) : true;
const allowExtractZip = !inEncrypted && (caps ? !!caps.canExtract : true);
const allowZip = !inEncrypted; // zip-create is blocked inside encrypted folders
@@ -151,6 +156,7 @@ function currentSelection() {
any, one, many, anyZip,
file,
canEdit: canEditFlag,
allowLink,
allowShare,
allowZip,
allowExtractZip
@@ -250,6 +256,10 @@ function menuClickDelegate(ev) {
if (s.file) openTagModal(s.file);
break;
case 'link_file':
if (s.file) openFileLinkModal(s.file, folder);
break;
case 'share_file':
if (s.file) openShareModal(s.file, folder);
break;
+199 -61
View File
@@ -2,6 +2,85 @@
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
async function copyTextToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "absolute";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch (e) {
ok = false;
} finally {
ta.remove();
}
return ok;
}
function openFolderShareResultModal(link) {
const existing = document.getElementById("folderShareResultModal");
if (existing) existing.remove();
const modal = document.createElement("div");
modal.id = "folderShareResultModal";
modal.className = "modal";
modal.innerHTML = `
<div class="modal-content share-modal-content" style="max-width:560px;">
<div class="modal-header">
<h3>${t("share_link_generated")}</h3>
<span id="closeFolderShareResultModal" title="${t("close")}" class="close-image-modal">&times;</span>
</div>
<div class="modal-body">
<p style="margin-bottom:6px;">${t("shareable_link")}</p>
<input id="folderShareResultLinkInput" type="text" readonly style="width:100%;padding:6px;" />
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button id="copyFolderShareResultBtn" class="btn btn-primary">${t("copy_link")}</button>
<button id="closeFolderShareResultBtn" class="btn btn-secondary">${t("close")}</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = "block";
const inputEl = document.getElementById("folderShareResultLinkInput");
if (inputEl) {
inputEl.value = link;
inputEl.focus();
inputEl.select();
}
const close = () => modal.remove();
const closeX = document.getElementById("closeFolderShareResultModal");
const closeBtn = document.getElementById("closeFolderShareResultBtn");
const copyBtn = document.getElementById("copyFolderShareResultBtn");
if (closeX) closeX.addEventListener("click", close);
if (closeBtn) closeBtn.addEventListener("click", close);
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
if (copyBtn && inputEl) {
copyBtn.addEventListener("click", async () => {
try {
const ok = await copyTextToClipboard(inputEl.value);
showToast(ok ? t("link_copied") : t("unknown_error"));
} catch (e) {
showToast(t("unknown_error"));
}
});
}
}
export function openFolderShareModal(folder) {
// Remove any existing modal
const existing = document.getElementById("folderShareModal");
@@ -12,70 +91,86 @@ export function openFolderShareModal(folder) {
modal.id = "folderShareModal";
modal.classList.add("modal");
modal.innerHTML = `
<div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
<div class="modal-content share-modal-content">
<div class="modal-header">
<h3>${t("share_folder")}: ${escapeHTML(folder)}</h3>
<h3>${t("share_folder_and_request")}: ${escapeHTML(folder)}</h3>
<span id="closeFolderShareModal" title="${t("close")}" class="close-image-modal">&times;</span>
</div>
<div class="modal-body">
<p>${t("set_expiration")}</p>
<select id="folderShareExpiration" style="width:100%;padding:5px;">
<option value="30">30 ${t("minutes")}</option>
<option value="60" selected>60 ${t("minutes")}</option>
<option value="120">120 ${t("minutes")}</option>
<option value="180">180 ${t("minutes")}</option>
<option value="240">240 ${t("minutes")}</option>
<option value="1440">1 ${t("day")}</option>
<option value="custom">${t("custom")}&hellip;</option>
</select>
<div class="modal-body share-modal-body">
<p class="share-modal-helper">${t("share_folder_and_request_helper")}</p>
<div id="customFolderExpirationContainer" style="display:none;margin-top:10px;">
<label for="customFolderExpirationValue">${t("duration")}:</label>
<input type="number" id="customFolderExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
<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 class="share-modal-section">
<p class="share-section-title">${t("share_mode_heading")}</p>
<div class="share-mode-toggle" role="group" aria-label="${t("share_mode_heading")}">
<button type="button" id="folderShareBrowseModeBtn" class="share-mode-btn is-active">
<span class="share-mode-btn-title">${t("share_mode_browse_label")}</span>
<span class="share-mode-btn-desc">${t("share_mode_browse_desc")}</span>
</button>
<button type="button" id="folderShareRequestModeBtn" class="share-mode-btn">
<span class="share-mode-btn-title">${t("share_mode_request_label")}</span>
<span class="share-mode-btn-desc">${t("share_mode_request_desc")}</span>
</button>
</div>
<p id="folderShareModeNotice" class="share-mode-notice"></p>
<input type="checkbox" id="folderShareDropMode" hidden />
</div>
<p style="margin-top:15px;">${t("password_optional")}</p>
<input
type="text"
id="folderSharePassword"
placeholder="${t("enter_password")}"
style="width:100%;padding:5px;"
/>
<div class="share-modal-section">
<p class="share-section-title">${t("share_link_settings")}</p>
<label class="share-field-label" for="folderShareExpiration">${t("set_expiration")}</label>
<select id="folderShareExpiration" class="share-field-input">
<option value="30">30 ${t("minutes")}</option>
<option value="60" selected>60 ${t("minutes")}</option>
<option value="120">120 ${t("minutes")}</option>
<option value="180">180 ${t("minutes")}</option>
<option value="240">240 ${t("minutes")}</option>
<option value="1440">1 ${t("day")}</option>
<option value="custom">${t("custom")}&hellip;</option>
</select>
<label style="margin-top:10px;display:block;">
<input type="checkbox" id="folderShareAllowUpload" />
${t("allow_uploads")}
</label>
<div id="customFolderExpirationContainer" class="share-custom-expiration" style="display:none;">
<label for="customFolderExpirationValue">${t("duration")}:</label>
<input type="number" id="customFolderExpirationValue" min="1" value="1" />
<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">
${t("custom_duration_warning")}
</p>
</div>
<label style="margin-top:6px;display:block;">
<input type="checkbox" id="folderShareAllowSubfolders" />
${t("allow_subfolders")}
</label>
<label class="share-field-label" for="folderSharePassword">${t("password_optional")}</label>
<input
type="text"
id="folderSharePassword"
placeholder="${t("enter_password")}"
class="share-field-input"
/>
</div>
<div class="share-modal-section">
<p class="share-section-title">${t("share_upload_settings")}</p>
<label class="share-check">
<input type="checkbox" id="folderShareAllowUpload" />
<span>${t("allow_uploads")}</span>
</label>
<label class="share-check">
<input type="checkbox" id="folderShareAllowSubfolders" />
<span>${t("allow_subfolders")}</span>
</label>
<div class="share-check-helper">${t("allow_subfolders_helper")}</div>
</div>
<button
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>
<input type="text" id="folderShareLinkInput" readonly style="width:100%;padding:5px;"/>
<button id="copyFolderShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
${t("copy_link")}
</button>
</div>
</div>
</div>
`;
@@ -93,6 +188,57 @@ export function openFolderShareModal(folder) {
.style.display = e.target.value === "custom" ? "block" : "none";
});
const allowUploadEl = document.getElementById("folderShareAllowUpload");
const dropModeEl = document.getElementById("folderShareDropMode");
const browseModeBtn = document.getElementById("folderShareBrowseModeBtn");
const requestModeBtn = document.getElementById("folderShareRequestModeBtn");
const modeNoticeEl = document.getElementById("folderShareModeNotice");
const syncModeVisuals = () => {
if (!dropModeEl) return;
const dropEnabled = !!dropModeEl.checked;
if (browseModeBtn) browseModeBtn.classList.toggle("is-active", !dropEnabled);
if (requestModeBtn) requestModeBtn.classList.toggle("is-active", dropEnabled);
if (modeNoticeEl) {
modeNoticeEl.textContent = dropEnabled
? t("share_mode_notice_request")
: t("share_mode_notice_browse");
}
};
const syncDropMode = () => {
if (!allowUploadEl || !dropModeEl) return;
if (dropModeEl.checked) {
allowUploadEl.checked = true;
allowUploadEl.disabled = true;
} else {
allowUploadEl.disabled = false;
}
syncModeVisuals();
};
if (allowUploadEl && dropModeEl) {
if (browseModeBtn) {
browseModeBtn.addEventListener("click", () => {
dropModeEl.checked = false;
syncDropMode();
});
}
if (requestModeBtn) {
requestModeBtn.addEventListener("click", () => {
dropModeEl.checked = true;
syncDropMode();
});
}
allowUploadEl.addEventListener("change", () => {
if (!allowUploadEl.checked && dropModeEl.checked) {
dropModeEl.checked = false;
}
syncDropMode();
});
syncDropMode();
}
// Generate link
document.getElementById("generateFolderShareLinkBtn")
.addEventListener("click", () => {
@@ -109,6 +255,7 @@ export function openFolderShareModal(folder) {
const password = document.getElementById("folderSharePassword").value;
const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0;
const allowSubfolders = document.getElementById("folderShareAllowSubfolders").checked ? 1 : 0;
const dropMode = document.getElementById("folderShareDropMode").checked ? 1 : 0;
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
if (!csrfToken) {
showToast(t("csrf_error"));
@@ -128,15 +275,15 @@ export function openFolderShareModal(folder) {
expirationUnit: unit,
password,
allowUpload,
allowSubfolders
allowSubfolders,
mode: dropMode ? "drop" : "browse",
fileDrop: dropMode
})
})
.then(r => r.json())
.then(data => {
if (data.token && data.link) {
document.getElementById("folderShareLinkInput").value = data.link;
document.getElementById("folderShareLinkDisplay").style.display = "block";
showToast(t("share_link_generated"));
openFolderShareResultModal(data.link);
} else {
showToast(t("error_generating_share_link") + ": " + (data.error||t("unknown_error")));
}
@@ -146,13 +293,4 @@ export function openFolderShareModal(folder) {
showToast(t("error_generating_share_link") + ": " + t("unknown_error"));
});
});
// Copy
document.getElementById("copyFolderShareLinkBtn")
.addEventListener("click", () => {
const inp = document.getElementById("folderShareLinkInput");
inp.select();
document.execCommand("copy");
showToast(t("link_copied"));
});
}
+45 -1
View File
@@ -37,6 +37,7 @@ const translations = {
"no_files_found": "No files found.",
"switch_to_table_view": "Switch to Table View",
"switch_to_gallery_view": "Switch to Gallery View",
"link_file": "Link File",
"share_file": "Share File",
"set_expiration": "Set Expiration:",
"password_optional": "Password (optional):",
@@ -187,8 +188,44 @@ const translations = {
// Folder Share
"share_folder": "Share Folder",
"share_folder_and_request": "Share Folder / File Request",
"share_folder_and_request_helper": "Create a normal shared link, or enable upload-only file request mode.",
"share_mode_heading": "Link mode",
"share_mode_browse_label": "Folder Share",
"share_mode_browse_desc": "View/download shared files. Uploads are optional.",
"share_mode_request_label": "Request Files",
"share_mode_request_desc": "Upload-only drop link. Existing files stay hidden.",
"share_mode_notice_browse": "Folder Share mode lets recipients browse and download files. Enable uploads if you also want file submissions.",
"share_mode_notice_request": "Request Files mode is upload-only. Recipients cannot view or download existing files.",
"share_link_settings": "Link settings",
"share_upload_settings": "Upload settings",
"allow_uploads": "Allow Uploads",
"upload_only_file_drop": "Upload-only (File Request / File Drop)",
"upload_only_helper": "Uploaders can't see existing files.",
"allow_subfolders": "Include Subfolders",
"allow_subfolders_helper": "Required for folder uploads in file request mode.",
"share_uploading": "Uploading...",
"share_uploading_progress": "Uploading... {pct}%",
"share_upload_complete_refreshing": "Upload complete. Refreshing...",
"share_upload_failed": "Upload failed.",
"share_upload_failed_http": "Upload failed (HTTP {code}): {reason}",
"share_upload_failed_message": "Upload failed: {reason}",
"share_upload_network_error": "Network error. Please check your connection and try again.",
"share_upload_selection_unavailable": "Selection unavailable",
"share_upload_selection_read_error": "Could not read selected file.",
"share_drop_root_label": "Upload files",
"share_drop_rule_max_file_size": "Max file size: {mb} MB",
"share_drop_rule_allowed_types": "Allowed types: {types}",
"share_drop_rule_daily_file_limit": "Daily file limit: {count}",
"share_drop_rule_daily_size_limit": "Daily size limit: {mb} MB",
"share_drop_rule_preserve_structure": "Folder structure is preserved when available.",
"share_drop_status_queued": "Queued",
"share_drop_status_uploaded": "Uploaded",
"share_drop_status_failed": "Failed",
"share_drop_error_invalid_file": "Invalid file.",
"share_drop_error_subfolders_disabled": "Skipped: subfolder uploads are not enabled for this share.",
"share_drop_error_size_exceeded": "Skipped: file is larger than {mb} MB.",
"share_drop_error_type_not_allowed": "Skipped: file type not allowed.",
"share_link_generated": "Share Link Generated",
"error_generating_share_link": "Error Generating Share Link",
"custom": "Custom",
@@ -200,7 +237,7 @@ const translations = {
"custom_duration_warning": "⚠️ Using a long expiration may pose security risks. Use with caution.",
// Folder
"folder_share": "Share Folder",
"folder_share": "Share Folder / File Request",
// Custom Confirm Modal keys:
"yes": "Yes",
@@ -214,6 +251,11 @@ const translations = {
"user": "User:",
"unknown_error": "Unknown Error",
"link_copied": "Link Copied to Clipboard",
"file_link_create_failed": "Could not create file link.",
"file_link_create_failed_detail": "Could not create file link: {error}",
"file_link_invalid_or_expired": "This file link is invalid or expired.",
"file_link_resolve_failed": "Could not open linked file.",
"file_link_opened": "Opened linked file.",
"weeks": "weeks",
"months": "months",
@@ -234,7 +276,9 @@ const translations = {
"manage_shared_links": "Manage Shared Links",
"manage_shared_links_size": "Manage Shared Links & Upload Size Limit",
"folder_shares": "Folder Shares",
"file_requests": "File Requests",
"file_shares": "File Shares",
"shared_created_by": "by {user}",
"loading": "Loading…",
"error_loading_share_links": "Error loading share links",
"share_deleted_successfully": "Share deleted successfully",
+170 -28
View File
@@ -110,6 +110,166 @@ function showLoginTip(message) {
const LOGIN_FAIL_STORAGE_KEY = 'fr_login_fail_state';
const LOGIN_FAIL_WINDOW_MS = 30 * 60 * 1000;
const LOGIN_FAIL_MAX = 5;
const AUTH_FILE_LINK_PARAM = 'fileLink';
const AUTH_FILE_LINK_RETURN_KEY = 'fr_auth_file_link_return';
const AUTH_FILE_LINK_TOKEN_RE = /^[a-f0-9]{64}$/i;
// ---- Safe redirect helper (prevents open redirects) ----
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
if (!raw) return fallback;
try {
const str = String(raw).trim();
if (!str) return fallback;
// Resolve against current page so relative paths keep subpath mounts (e.g. /fr).
const candidate = new URL(str, window.location.href);
// Enforce same-origin
if (candidate.origin !== window.location.origin) {
return fallback;
}
// Limit to http/https
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
return fallback;
}
// Return relative URL
return candidate.pathname + candidate.search + candidate.hash;
} catch (e) {
return fallback;
}
}
function getCurrentRelativeUrl() {
return window.location.pathname + window.location.search + window.location.hash;
}
function rememberAuthFileLinkReturnUrl() {
try {
const current = new URL(window.location.href);
const token = String(current.searchParams.get(AUTH_FILE_LINK_PARAM) || '').trim();
if (!token) return;
const safe = sanitizeRedirect(getCurrentRelativeUrl(), { fallback: null });
if (safe) {
sessionStorage.setItem(AUTH_FILE_LINK_RETURN_KEY, safe);
}
} catch (e) {
// ignore
}
}
function consumeAuthFileLinkReturnUrl() {
try {
const raw = sessionStorage.getItem(AUTH_FILE_LINK_RETURN_KEY) || '';
sessionStorage.removeItem(AUTH_FILE_LINK_RETURN_KEY);
if (!raw) return '';
const safe = sanitizeRedirect(raw, { fallback: null });
return safe || '';
} catch (e) {
return '';
}
}
function maybeRestoreAuthFileLinkReturnUrl() {
const target = consumeAuthFileLinkReturnUrl();
if (!target) return false;
try {
const targetUrl = new URL(target, window.location.href);
const token = String(targetUrl.searchParams.get(AUTH_FILE_LINK_PARAM) || '').trim();
if (!token) return false;
const currentSafe = sanitizeRedirect(getCurrentRelativeUrl(), { fallback: '' }) || '';
if (target === currentSafe) return false;
window.location.replace(target);
return true;
} catch (e) {
return false;
}
}
function clearAuthFileLinkFromUrl() {
try {
const url = new URL(window.location.href);
if (!url.searchParams.has(AUTH_FILE_LINK_PARAM)) return;
url.searchParams.delete(AUTH_FILE_LINK_PARAM);
const next =
url.pathname +
(url.search ? url.search : '') +
(url.hash || '');
window.history.replaceState(window.history.state || null, '', next);
} catch (e) {
// ignore
}
}
async function resolveAuthFileLinkIfPresent() {
let token = '';
try {
const url = new URL(window.location.href);
token = String(url.searchParams.get(AUTH_FILE_LINK_PARAM) || '').trim();
} catch (e) {
token = '';
}
if (!token) return;
if (!AUTH_FILE_LINK_TOKEN_RE.test(token)) {
window.showToast(t('file_link_invalid_or_expired'), 'error');
clearAuthFileLinkFromUrl();
return;
}
try {
const sourceInitPromise = window.__frSourceInitPromise;
if (sourceInitPromise && typeof sourceInitPromise.then === 'function') {
await sourceInitPromise.catch(() => {});
}
} catch (e) {
// ignore
}
try {
const res = await fetch(withBase('/api/file/resolveAuthFileLink.php?token=' + encodeURIComponent(token)), {
method: 'GET',
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data || data.ok !== true) {
if (res.status === 403) {
window.showToast(t('no_access_to_resource'), 'error');
} else if (res.status === 404 || res.status === 410) {
window.showToast(t('file_link_invalid_or_expired'), 'error');
} else {
const msg = (data && (data.error || data.message)) || t('file_link_resolve_failed');
window.showToast(msg, 'error');
}
return;
}
const folder = String(data.folder || 'root');
const file = String(data.file || '').trim();
const sourceId = String(data.sourceId || '').trim();
if (!file) {
window.showToast(t('file_link_invalid_or_expired'), 'error');
return;
}
const list = await import(withBase('/js/fileListView.js?v={{APP_QVER}}')).catch(async () => {
return import(withBase('/js/fileListView.js'));
});
if (list && typeof list.navigateToLinkedFile === 'function') {
await list.navigateToLinkedFile(folder, file, sourceId);
} else if (typeof window.loadFileList === 'function') {
window.currentFolder = folder || 'root';
await window.loadFileList(folder || 'root');
}
window.showToast(t('file_link_opened'), 'success');
} catch (e) {
window.showToast(t('file_link_resolve_failed'), 'error');
} finally {
clearAuthFileLinkFromUrl();
try { sessionStorage.removeItem(AUTH_FILE_LINK_RETURN_KEY); } catch (e) { }
}
}
function readLoginFailState(now = Date.now()) {
try {
@@ -581,33 +741,6 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
return p.then(r => r.clone());
};
// ---- Safe redirect helper (prevents open redirects) ----
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
if (!raw) return fallback;
try {
const str = String(raw).trim();
if (!str) return fallback;
// Resolve against current page so relative paths keep subpath mounts (e.g. /fr).
const candidate = new URL(str, window.location.href);
// Enforce same-origin
if (candidate.origin !== window.location.origin) {
return fallback;
}
// Limit to http/https
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
return fallback;
}
// Return relative URL
return candidate.pathname + candidate.search + candidate.hash;
} catch (e) {
return fallback;
}
}
// Gentle toast normalizer (compatible with showToast(message, duration))
const origToast = window.showToast;
if (typeof origToast === 'function' && !origToast.__frWrapped) {
@@ -1562,7 +1695,10 @@ function bindDarkMode() {
const oidcBtn = $('#oidcLoginBtn');
if (oidcBtn && !oidcBtn.__bound) {
oidcBtn.__bound = true;
oidcBtn.addEventListener('click', () => { window.location.href = withBase('/api/auth/auth.php?oidc=initiate'); });
oidcBtn.addEventListener('click', () => {
rememberAuthFileLinkReturnUrl();
window.location.href = withBase('/api/auth/auth.php?oidc=initiate');
});
}
const form = $('#authForm');
@@ -1799,6 +1935,8 @@ function bindDarkMode() {
// 3) auth/header bits — pass real state so “Admin Panel” shows up
await resolveAuthFileLinkIfPresent();
if (!window.__FR_FLAGS.wired.auth) {
try {
const auth = await import(withBase('/js/auth.js?v={{APP_QVER}}')).catch(async (err) => {
@@ -1878,6 +2016,9 @@ function bindDarkMode() {
if (authed) {
// Authenticated path: show app, hide login
if (maybeRestoreAuthFileLinkReturnUrl()) {
return;
}
document.body.classList.remove('fr-login-view');
document.body.classList.add('authed');
unhide(wrap); // works whether CSS or [hidden] was used
@@ -1897,6 +2038,7 @@ function bindDarkMode() {
unhide(mainEl);
unhide(login);
if (login) login.style.display = '';
rememberAuthFileLinkReturnUrl();
// …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
applyDarkMode();
+775
View File
@@ -0,0 +1,775 @@
// sharedDropView.js
import { setLocale, t } from './i18n.js?v={{APP_QVER}}';
document.addEventListener('DOMContentLoaded', async function () {
try {
const saved = localStorage.getItem('language') || 'en';
await setLocale(saved);
} catch (e) {
await setLocale('en');
}
const tx = (key, placeholders, fallback) => {
const out = t(key, placeholders);
if (out === key && typeof fallback === 'string') {
return fallback;
}
return out;
};
const dataEl = document.getElementById('shared-data');
if (!dataEl) return;
let payload = {};
try {
payload = JSON.parse(dataEl.textContent || '{}');
} catch (e) {
payload = {};
}
const mode = String(payload.mode || '').toLowerCase();
const hideListing = !!payload.hideListing;
if (mode !== 'drop' && !hideListing) {
return;
}
const token = String(payload.token || '');
const shareRoot = String(payload.shareRoot || 'root');
const currentPath = String(payload.path || '');
const allowSubfolders = !!payload.allowSubfolders;
const preserveFolderStructure = payload.preserveFolderStructure !== 0;
const maxFileSizeMb = Number.isFinite(payload.maxFileSizeMb) ? Number(payload.maxFileSizeMb) : 0;
const maxFileSizeBytes = maxFileSizeMb > 0 ? Math.round(maxFileSizeMb * 1024 * 1024) : 0;
const allowedTypes = Array.isArray(payload.allowedTypes)
? payload.allowedTypes.map((x) => String(x || '').trim().toLowerCase()).filter(Boolean)
: [];
const dailyFileLimit = Number.isFinite(payload.dailyFileLimit) ? Number(payload.dailyFileLimit) : 0;
const maxTotalMbPerDay = Number.isFinite(payload.maxTotalMbPerDay) ? Number(payload.maxTotalMbPerDay) : 0;
const form = document.getElementById('shareDropUploadForm');
const dropzone = document.getElementById('shareDropzone');
const fileInput = document.getElementById('shareDropFileInput');
const folderInput = document.getElementById('shareDropFolderInput');
const chooseFilesBtn = document.getElementById('shareChooseFilesBtn');
const chooseFolderBtn = document.getElementById('shareChooseFolderBtn');
const queueEl = document.getElementById('shareDropQueue');
const rulesEl = document.getElementById('shareDropRules');
const breadcrumbsEl = document.getElementById('shareBreadcrumbs');
const themeToggleBtn = document.getElementById('shareThemeToggle');
const dropUploadErrorId = 'shareDropUploadError';
if (!form || !dropzone || !fileInput || !folderInput || !queueEl) {
return;
}
function ensureDropUploadErrorEl() {
let el = document.getElementById(dropUploadErrorId);
if (el) return el;
el = document.createElement('div');
el.id = dropUploadErrorId;
el.className = 'fr-share-alert fr-share-alert-error fr-share-upload-error';
el.setAttribute('role', 'alert');
el.hidden = true;
if (form.parentNode) {
form.parentNode.insertBefore(el, form.nextSibling);
}
return el;
}
function clearDropUploadError() {
const el = document.getElementById(dropUploadErrorId);
if (!el) return;
el.hidden = true;
el.textContent = '';
}
function showDropUploadError(message, statusCode) {
const el = ensureDropUploadErrorEl();
if (!el) return;
const reason = String(message || tx('share_upload_failed', null, 'Upload failed.')).trim()
|| tx('share_upload_failed', null, 'Upload failed.');
const code = Number.isFinite(statusCode) && statusCode > 0 ? Math.trunc(statusCode) : 0;
el.textContent = code > 0
? tx('share_upload_failed_http', { code, reason }, 'Upload failed (HTTP ' + code + '): ' + reason)
: tx('share_upload_failed_message', { reason }, 'Upload failed: ' + reason);
el.hidden = false;
}
const THEME_KEY = 'fr_share_theme';
function getStoredTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
return (t === 'light' || t === 'dark') ? t : 'auto';
} catch (e) {
return 'auto';
}
}
function setStoredTheme(theme) {
try {
localStorage.setItem(THEME_KEY, theme);
} catch (e) {
// ignore
}
}
function getSystemTheme() {
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
? 'dark'
: 'light';
}
function getActiveTheme(storedTheme) {
return (storedTheme === 'light' || storedTheme === 'dark') ? storedTheme : getSystemTheme();
}
function applyTheme(theme) {
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-share-theme', theme);
} else {
document.documentElement.removeAttribute('data-share-theme');
}
}
function updateThemeLabel(storedTheme) {
if (!themeToggleBtn) return;
const active = getActiveTheme(storedTheme);
themeToggleBtn.textContent = active === 'dark' ? 'Light mode' : 'Dark mode';
}
if (themeToggleBtn) {
const storedTheme = getStoredTheme();
applyTheme(storedTheme);
updateThemeLabel(storedTheme);
themeToggleBtn.addEventListener('click', function () {
const currentStored = getStoredTheme();
const active = getActiveTheme(currentStored);
const next = active === 'dark' ? 'light' : 'dark';
setStoredTheme(next);
applyTheme(next);
updateThemeLabel(next);
});
}
function getBasePathFromLocation() {
try {
let p = String(window.location.pathname || '');
p = p.replace(/\/api\/folder\/shareFolder\.php$/i, '');
p = p.replace(/\/+$/, '');
if (!p || p === '/') return '';
if (!p.startsWith('/')) p = '/' + p;
return p;
} catch (e) {
return '';
}
}
function withBasePath(path) {
const base = getBasePathFromLocation();
const s = String(path || '');
if (!base || !s.startsWith('/')) return s;
if (s === base || s.startsWith(base + '/')) return s;
return base + s;
}
function buildShareUrl(path) {
const urlParams = new URLSearchParams(window.location.search || '');
const pass = urlParams.get('pass') || '';
const passParam = pass ? '&pass=' + encodeURIComponent(pass) : '';
const p = path ? '&path=' + encodeURIComponent(path) : '';
return withBasePath('/api/folder/shareFolder.php?token=' + encodeURIComponent(token) + passParam + p);
}
function renderBreadcrumbs() {
if (!breadcrumbsEl) return;
while (breadcrumbsEl.firstChild) breadcrumbsEl.removeChild(breadcrumbsEl.firstChild);
const rootLabel = (shareRoot && shareRoot !== 'root')
? shareRoot.split('/').pop()
: tx('share_drop_root_label', null, 'Upload files');
const rootLink = document.createElement('a');
rootLink.href = buildShareUrl('');
rootLink.textContent = rootLabel;
breadcrumbsEl.appendChild(rootLink);
if (!allowSubfolders || !currentPath) return;
const parts = currentPath.split('/').filter(Boolean);
let acc = '';
parts.forEach((part) => {
acc = acc ? acc + '/' + part : part;
const sep = document.createElement('span');
sep.className = 'fr-share-breadcrumb-sep';
sep.textContent = '/';
breadcrumbsEl.appendChild(sep);
const link = document.createElement('a');
link.href = buildShareUrl(acc);
link.textContent = part;
breadcrumbsEl.appendChild(link);
});
}
function formatBytes(bytes) {
const n = Number(bytes || 0);
if (!Number.isFinite(n) || n <= 0) return '0 B';
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(2) + ' KB';
if (n < 1073741824) return (n / 1048576).toFixed(2) + ' MB';
return (n / 1073741824).toFixed(2) + ' GB';
}
function renderRules() {
if (!rulesEl) return;
rulesEl.textContent = '';
const parts = [];
if (maxFileSizeMb > 0) {
parts.push(tx('share_drop_rule_max_file_size', { mb: maxFileSizeMb }, 'Max file size: ' + maxFileSizeMb + ' MB'));
}
if (allowedTypes.length) {
const joined = allowedTypes.join(', ');
parts.push(tx('share_drop_rule_allowed_types', { types: joined }, 'Allowed types: ' + joined));
}
if (dailyFileLimit > 0) {
parts.push(tx('share_drop_rule_daily_file_limit', { count: dailyFileLimit }, 'Daily file limit: ' + dailyFileLimit));
}
if (maxTotalMbPerDay > 0) {
parts.push(tx('share_drop_rule_daily_size_limit', { mb: maxTotalMbPerDay }, 'Daily size limit: ' + maxTotalMbPerDay + ' MB'));
}
if (preserveFolderStructure) {
parts.push(tx('share_drop_rule_preserve_structure', null, 'Folder structure is preserved when available.'));
}
if (!parts.length) return;
parts.forEach((msg) => {
const tag = document.createElement('span');
tag.className = 'fr-share-rule-pill';
tag.textContent = msg;
rulesEl.appendChild(tag);
});
}
function getExt(name) {
const idx = name.lastIndexOf('.');
if (idx === -1) return '';
return name.slice(idx + 1).toLowerCase();
}
function sanitizeRelativePath(raw, fallbackName) {
let path = String(raw || '').replace(/\\/g, '/').trim();
path = path.replace(/^\/+/, '').replace(/\/+$/, '');
if (!path) return fallbackName;
path = path.replace(/\/+/g, '/');
const parts = path.split('/').filter(Boolean);
const clean = [];
for (let i = 0; i < parts.length; i++) {
const seg = parts[i].trim();
if (!seg || seg === '.' || seg === '..') {
continue;
}
clean.push(seg);
}
if (!clean.length) {
return fallbackName;
}
return clean.join('/');
}
function getRelativePathForItem(file) {
const raw = preserveFolderStructure
? (file.webkitRelativePath || file.relativePath || file.name)
: file.name;
return sanitizeRelativePath(raw, file.name);
}
function appendCommonFormData(formData) {
const tokenInput = form.querySelector('input[name="token"]');
const passInput = form.querySelector('input[name="pass"]');
const pathInput = form.querySelector('input[name="path"]');
const shareTokenInput = form.querySelector('input[name="share_upload_token"]');
if (tokenInput && tokenInput.value) formData.append('token', tokenInput.value);
if (passInput && passInput.value) formData.append('pass', passInput.value);
if (pathInput && pathInput.value) formData.append('path', pathInput.value);
if (shareTokenInput && shareTokenInput.value) formData.append('share_upload_token', shareTokenInput.value);
formData.append('response', 'json');
}
function xhrJson(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
if (typeof onProgress === 'function') {
xhr.upload.addEventListener('progress', onProgress);
}
xhr.addEventListener('load', function () {
let data = null;
let rawMessage = '';
try {
data = JSON.parse(xhr.responseText || '{}');
} catch (e) {
rawMessage = String(xhr.responseText || '').replace(/\s+/g, ' ').trim();
if (rawMessage.length > 180) {
rawMessage = rawMessage.slice(0, 180) + '...';
}
data = { error: rawMessage || tx('share_upload_failed', null, 'Upload failed.') };
}
if (xhr.status >= 200 && xhr.status < 300 && !data.error) {
resolve(data);
return;
}
const msg = data && data.error
? String(data.error)
: tx('share_upload_failed_http', {
code: xhr.status,
reason: tx('share_upload_failed', null, 'Upload failed.'),
}, 'Upload failed (HTTP ' + xhr.status + ').');
const err = new Error(msg);
err.status = xhr.status || 0;
reject(err);
});
xhr.addEventListener('error', function () {
const err = new Error(tx('share_upload_network_error', null, 'Network error. Please check your connection and try again.'));
err.status = 0;
reject(err);
});
xhr.send(formData);
});
}
const queue = [];
const rows = new Map();
let running = 0;
let uploadSequence = 0;
const MAX_CONCURRENT = 2;
const CHUNK_THRESHOLD = 8 * 1024 * 1024;
const CHUNK_SIZE = 2 * 1024 * 1024;
const uploadUrl = form.getAttribute('action') || withBasePath('/api/folder/uploadToSharedFolder.php');
function prependRow(parent, child) {
if (!parent || !child) return;
if (typeof parent.prepend === 'function') {
parent.prepend(child);
return;
}
if (parent.firstChild) {
parent.insertBefore(child, parent.firstChild);
} else {
parent.appendChild(child);
}
}
function isFileLike(file) {
return !!file && typeof file === 'object' && typeof file.name === 'string';
}
function ensureRow(item) {
if (rows.has(item.id)) return rows.get(item.id);
const row = document.createElement('div');
row.className = 'fr-share-queue-item';
const head = document.createElement('div');
head.className = 'fr-share-queue-head';
const nameEl = document.createElement('div');
nameEl.className = 'fr-share-queue-name';
nameEl.textContent = item.relativePath;
const statusEl = document.createElement('div');
statusEl.className = 'fr-share-queue-status';
statusEl.textContent = tx('share_drop_status_queued', null, 'Queued');
head.appendChild(nameEl);
head.appendChild(statusEl);
const meta = document.createElement('div');
meta.className = 'fr-share-queue-meta';
meta.textContent = isFileLike(item.file) ? formatBytes(item.file.size) : '-';
const progressWrap = document.createElement('div');
progressWrap.className = 'fr-share-queue-progress';
const progressFill = document.createElement('div');
progressFill.className = 'fr-share-queue-progress-fill';
progressFill.style.width = '0%';
progressWrap.appendChild(progressFill);
row.appendChild(head);
row.appendChild(meta);
row.appendChild(progressWrap);
prependRow(queueEl, row);
const refs = { row, statusEl, progressFill, meta };
rows.set(item.id, refs);
return refs;
}
function setItemStatus(item, status, pct, message) {
const refs = ensureRow(item);
item.status = status;
item.progress = Math.max(0, Math.min(100, Math.round(Number(pct || 0))));
refs.progressFill.style.width = item.progress + '%';
if (status === 'uploading') {
refs.row.classList.remove('is-error', 'is-done');
refs.statusEl.textContent = message || tx('share_uploading_progress', { pct: item.progress }, 'Uploading ' + item.progress + '%');
return;
}
if (status === 'done') {
refs.row.classList.remove('is-error');
refs.row.classList.add('is-done');
refs.statusEl.textContent = message || tx('share_drop_status_uploaded', null, 'Uploaded');
refs.progressFill.style.width = '100%';
return;
}
if (status === 'error') {
refs.row.classList.add('is-error');
refs.row.classList.remove('is-done');
refs.statusEl.textContent = message || tx('share_drop_status_failed', null, 'Failed');
return;
}
refs.statusEl.textContent = message || tx('share_drop_status_queued', null, 'Queued');
}
function validateQueueItem(item) {
if (!item || !isFileLike(item.file)) {
return tx('share_drop_error_invalid_file', null, 'Invalid file.');
}
if (!allowSubfolders && String(item.relativePath || '').indexOf('/') !== -1) {
return tx(
'share_drop_error_subfolders_disabled',
null,
'Skipped: subfolder uploads are not enabled for this share.'
);
}
if (maxFileSizeBytes > 0 && item.file.size > maxFileSizeBytes) {
return tx(
'share_drop_error_size_exceeded',
{ mb: maxFileSizeMb },
'Skipped: file is larger than ' + maxFileSizeMb + ' MB.'
);
}
if (allowedTypes.length) {
const ext = getExt(item.file.name || '');
if (!ext || !allowedTypes.includes(ext)) {
return tx('share_drop_error_type_not_allowed', null, 'Skipped: file type not allowed.');
}
}
return '';
}
function makeUploadId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
return window.crypto.randomUUID().replace(/-/g, '');
}
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
const arr = new Uint8Array(16);
window.crypto.getRandomValues(arr);
return Array.from(arr).map((n) => n.toString(16).padStart(2, '0')).join('');
}
} catch (e) {
// ignore
}
uploadSequence += 1;
return 'upl' + String(Date.now()) + '_' + String(uploadSequence);
}
async function uploadSingle(item) {
const formData = new FormData();
appendCommonFormData(formData);
const rel = getRelativePathForItem(item.file);
if (rel !== item.file.name) {
formData.append('relativePath', rel);
}
formData.append('fileToUpload', item.file, item.file.name);
await xhrJson(uploadUrl, formData, function (evt) {
if (!evt.lengthComputable) return;
const pct = Math.min(100, Math.max(0, Math.round((evt.loaded / evt.total) * 100)));
setItemStatus(item, 'uploading', pct);
});
}
async function uploadChunked(item) {
const rel = getRelativePathForItem(item.file);
const totalChunks = Math.max(1, Math.ceil(item.file.size / CHUNK_SIZE));
const uploadId = makeUploadId();
for (let index = 1; index <= totalChunks; index++) {
const start = (index - 1) * CHUNK_SIZE;
const end = Math.min(item.file.size, start + CHUNK_SIZE);
const blob = item.file.slice(start, end);
const formData = new FormData();
appendCommonFormData(formData);
formData.append('resumableChunkNumber', String(index));
formData.append('resumableTotalChunks', String(totalChunks));
formData.append('resumableIdentifier', uploadId);
formData.append('resumableFilename', item.file.name);
formData.append('resumableTotalSize', String(item.file.size));
formData.append('resumableCurrentChunkSize', String(blob.size));
if (rel) {
formData.append('resumableRelativePath', rel);
}
formData.append('file', blob, item.file.name + '.part' + index);
const maxAttempts = 3;
let attempt = 0;
let sent = false;
while (attempt < maxAttempts && !sent) {
attempt += 1;
try {
await xhrJson(uploadUrl, formData, function (evt) {
if (!evt.lengthComputable) return;
const chunkPct = evt.total > 0 ? (evt.loaded / evt.total) : 0;
const base = (index - 1) / totalChunks;
const pct = Math.round((base + (chunkPct / totalChunks)) * 100);
setItemStatus(item, 'uploading', pct);
});
sent = true;
} catch (err) {
if (attempt >= maxAttempts) {
throw err;
}
}
}
const donePct = Math.round((index / totalChunks) * 100);
setItemStatus(item, 'uploading', donePct);
}
}
async function uploadItem(item) {
const validationMsg = validateQueueItem(item);
if (validationMsg) {
setItemStatus(item, 'error', 0, validationMsg);
return;
}
setItemStatus(item, 'uploading', 0, tx('share_uploading_progress', { pct: 0 }, 'Uploading 0%'));
if (item.file.size >= CHUNK_THRESHOLD && item.file.size > CHUNK_SIZE) {
await uploadChunked(item);
} else {
await uploadSingle(item);
}
setItemStatus(item, 'done', 100, tx('share_drop_status_uploaded', null, 'Uploaded'));
}
function runQueue() {
while (running < MAX_CONCURRENT) {
const next = queue.find((q) => q.status === 'queued');
if (!next) break;
running += 1;
setItemStatus(next, 'uploading', 0, tx('share_uploading_progress', { pct: 0 }, 'Uploading 0%'));
uploadItem(next)
.catch((err) => {
const msg = err && err.message ? err.message : tx('share_upload_failed', null, 'Upload failed.');
const statusCode = (err && Number.isFinite(err.status)) ? Number(err.status) : 0;
setItemStatus(next, 'error', next.progress || 0, msg);
showDropUploadError(msg, statusCode);
})
.finally(() => {
running -= 1;
runQueue();
});
}
}
function enqueueFiles(items) {
if (!Array.isArray(items) || !items.length) return;
clearDropUploadError();
let added = 0;
items.forEach((it) => {
const file = it.file;
if (!isFileLike(file)) return;
const relativePath = sanitizeRelativePath(
preserveFolderStructure ? (it.relativePath || file.webkitRelativePath || file.name) : file.name,
file.name
);
const id = makeUploadId() + '_' + String(queue.length + 1);
const row = {
id,
file,
relativePath,
status: 'queued',
progress: 0
};
queue.push(row);
setItemStatus(row, 'queued', 0, tx('share_drop_status_queued', null, 'Queued'));
added += 1;
});
if (added === 0) {
const synthetic = {
id: makeUploadId() + '_invalid',
file: { name: 'upload', size: 0 },
relativePath: tx('share_upload_selection_unavailable', null, 'Selection unavailable'),
status: 'error',
progress: 0
};
const errMsg = tx('share_upload_selection_read_error', null, 'Could not read selected file.');
setItemStatus(synthetic, 'error', 0, errMsg);
showDropUploadError(errMsg, 0);
return;
}
runQueue();
}
function filesFromInputList(list) {
return Array.from(list || []).map((file) => ({
file,
relativePath: file.webkitRelativePath || file.name
}));
}
function readAllDirectoryEntries(reader) {
return new Promise((resolve) => {
const entries = [];
const readBatch = function () {
reader.readEntries(function (batch) {
if (!batch || !batch.length) {
resolve(entries);
return;
}
for (let i = 0; i < batch.length; i++) {
entries.push(batch[i]);
}
readBatch();
});
};
readBatch();
});
}
async function walkEntry(entry, prefix) {
if (!entry) return [];
if (entry.isFile) {
return new Promise((resolve) => {
entry.file(function (file) {
const rel = prefix ? (prefix + file.name) : file.name;
resolve([{ file, relativePath: rel }]);
}, function () {
resolve([]);
});
});
}
if (!entry.isDirectory) {
return [];
}
const reader = entry.createReader();
const children = await readAllDirectoryEntries(reader);
let out = [];
for (let i = 0; i < children.length; i++) {
const child = children[i];
const childPrefix = prefix + entry.name + '/';
const nested = await walkEntry(child, childPrefix);
out = out.concat(nested);
}
return out;
}
async function filesFromDropEvent(e) {
const dt = e.dataTransfer;
if (!dt) return [];
if (dt.items && dt.items.length && typeof dt.items[0].webkitGetAsEntry === 'function') {
let out = [];
const entries = [];
for (let i = 0; i < dt.items.length; i++) {
const entry = dt.items[i].webkitGetAsEntry();
if (entry) entries.push(entry);
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isFile) {
const file = dt.items[i].getAsFile ? dt.items[i].getAsFile() : null;
if (file) {
out.push({ file, relativePath: file.name });
}
continue;
}
const nested = await walkEntry(entry, '');
out = out.concat(nested);
}
if (out.length) return out;
}
return filesFromInputList(dt.files || []);
}
if (chooseFilesBtn) {
chooseFilesBtn.addEventListener('click', function (e) {
e.preventDefault();
fileInput.click();
});
}
if (chooseFolderBtn) {
chooseFolderBtn.addEventListener('click', function (e) {
e.preventDefault();
folderInput.click();
});
}
fileInput.addEventListener('change', function (e) {
const items = filesFromInputList(e.target.files || []);
enqueueFiles(items);
fileInput.value = '';
});
folderInput.addEventListener('change', function (e) {
const items = filesFromInputList(e.target.files || []);
enqueueFiles(items);
folderInput.value = '';
});
['dragenter', 'dragover'].forEach(function (name) {
dropzone.addEventListener(name, function (e) {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('is-dragover');
});
});
['dragleave', 'drop'].forEach(function (name) {
dropzone.addEventListener(name, function (e) {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('is-dragover');
});
});
dropzone.addEventListener('drop', async function (e) {
const items = await filesFromDropEvent(e);
enqueueFiles(items);
});
dropzone.addEventListener('click', function () {
fileInput.click();
});
dropzone.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
renderBreadcrumbs();
renderRules();
});
+88 -10
View File
@@ -1,6 +1,22 @@
// sharedFolderView.js
import { setLocale, t } from './i18n.js?v={{APP_QVER}}';
document.addEventListener('DOMContentLoaded', async function () {
try {
const saved = localStorage.getItem('language') || 'en';
await setLocale(saved);
} catch (e) {
await setLocale('en');
}
const tx = (key, placeholders, fallback) => {
const out = t(key, placeholders);
if (out === key && typeof fallback === 'string') {
return fallback;
}
return out;
};
document.addEventListener('DOMContentLoaded', function () {
const dataEl = document.getElementById('shared-data');
if (!dataEl) return;
@@ -62,6 +78,10 @@ document.addEventListener('DOMContentLoaded', function () {
return (storedTheme === 'light' || storedTheme === 'dark') ? storedTheme : getSystemTheme();
}
function isFileLike(file) {
return !!file && typeof file === 'object' && typeof file.name === 'string';
}
function applyTheme(theme) {
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-share-theme', theme);
@@ -112,6 +132,45 @@ document.addEventListener('DOMContentLoaded', function () {
const progressWrap = document.getElementById('shareUploadProgress');
const progressFill = progressWrap ? progressWrap.querySelector('.fr-share-upload-progress-fill') : null;
const progressText = document.getElementById('shareUploadProgressText');
const uploadErrorId = 'shareUploadError';
const ensureUploadErrorEl = () => {
let el = document.getElementById(uploadErrorId);
if (el) return el;
el = document.createElement('div');
el.id = uploadErrorId;
el.className = 'fr-share-alert fr-share-alert-error fr-share-upload-error';
el.setAttribute('role', 'alert');
el.hidden = true;
const anchor = progressWrap || uploadForm;
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(el, anchor.nextSibling);
} else if (uploadForm && uploadForm.parentNode) {
uploadForm.parentNode.appendChild(el);
}
return el;
};
const hideUploadError = () => {
const el = document.getElementById(uploadErrorId);
if (!el) return;
el.hidden = true;
el.textContent = '';
};
const showUploadError = (message, statusCode) => {
const el = ensureUploadErrorEl();
if (!el) return;
const reason = String(message || tx('share_upload_failed', null, 'Upload failed.')).trim()
|| tx('share_upload_failed', null, 'Upload failed.');
const code = Number.isFinite(statusCode) && statusCode > 0 ? Math.trunc(statusCode) : 0;
el.textContent = code > 0
? tx('share_upload_failed_http', { code, reason }, 'Upload failed (HTTP ' + code + '): ' + reason)
: tx('share_upload_failed_message', { reason }, 'Upload failed: ' + reason);
el.hidden = false;
};
const setBusy = (busy) => {
if (submitBtn) submitBtn.disabled = !!busy;
@@ -123,26 +182,32 @@ document.addEventListener('DOMContentLoaded', function () {
progressWrap.hidden = false;
progressWrap.classList.remove('is-error', 'is-indeterminate');
if (progressFill) progressFill.style.width = '0%';
if (progressText) progressText.textContent = 'Uploading...';
if (progressText) progressText.textContent = tx('share_uploading', null, 'Uploading...');
};
const setIndeterminate = () => {
if (!progressWrap) return;
progressWrap.classList.add('is-indeterminate');
if (progressText) progressText.textContent = 'Uploading...';
if (progressText) progressText.textContent = tx('share_uploading', null, 'Uploading...');
};
const setProgress = (pct) => {
if (progressWrap) progressWrap.classList.remove('is-indeterminate');
if (progressFill) progressFill.style.width = pct + '%';
if (progressText) progressText.textContent = 'Uploading... ' + pct + '%';
if (progressText) {
progressText.textContent = tx(
'share_uploading_progress',
{ pct },
'Uploading... ' + pct + '%'
);
}
};
const setError = (msg) => {
if (!progressWrap) return;
progressWrap.classList.remove('is-indeterminate');
progressWrap.classList.add('is-error');
if (progressText) progressText.textContent = msg || 'Upload failed.';
if (progressText) progressText.textContent = msg || tx('share_upload_failed', null, 'Upload failed.');
};
uploadForm.addEventListener('submit', function (e) {
@@ -154,13 +219,17 @@ document.addEventListener('DOMContentLoaded', function () {
e.preventDefault();
uploadForm.dataset.busy = '1';
hideUploadError();
showProgress();
const formData = new FormData(uploadForm);
const fileKey = (fileInput && fileInput.name) ? fileInput.name : 'fileToUpload';
const existingFile = formData.get(fileKey);
if (!(existingFile instanceof File) || !existingFile.name) {
formData.set(fileKey, fileInput.files[0]);
if (!isFileLike(existingFile)) {
const selectedFile = fileInput.files[0];
if (selectedFile) {
formData.set(fileKey, selectedFile);
}
}
setBusy(true);
@@ -179,8 +248,15 @@ document.addEventListener('DOMContentLoaded', function () {
xhr.addEventListener('load', function () {
const ok = xhr.status >= 200 && xhr.status < 300;
if (ok) {
hideUploadError();
if (progressFill) progressFill.style.width = '100%';
if (progressText) progressText.textContent = 'Upload complete. Refreshing...';
if (progressText) {
progressText.textContent = tx(
'share_upload_complete_refreshing',
null,
'Upload complete. Refreshing...'
);
}
window.location.reload();
return;
}
@@ -190,13 +266,15 @@ document.addEventListener('DOMContentLoaded', function () {
const data = JSON.parse(xhr.responseText || '{}');
if (data && data.error) msg = String(data.error);
} catch (err) { }
setError(msg || 'Upload failed.');
showUploadError(msg || tx('share_upload_failed', null, 'Upload failed.'), xhr.status || 0);
setError(msg || tx('share_upload_failed', null, 'Upload failed.'));
uploadForm.dataset.busy = '0';
setBusy(false);
});
xhr.addEventListener('error', function () {
setError('Upload failed. Please try again.');
showUploadError(tx('share_upload_network_error', null, 'Network error. Please check your connection and try again.'), 0);
setError(tx('share_upload_failed', null, 'Upload failed.'));
uploadForm.dataset.busy = '0';
setBusy(false);
});
+200 -2
View File
@@ -2766,6 +2766,199 @@ class FileModel
return $response;
}
private static function authFileLinksPathForMetaRoot(string $metaRoot): string
{
return rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'auth_file_links.json';
}
private static function readAuthFileLinks(string $path): array
{
if (!is_file($path)) {
return [];
}
$decoded = json_decode((string)@file_get_contents($path), true);
return is_array($decoded) ? $decoded : [];
}
private static function cleanAuthFileLinks(array $links, int $now): array
{
$cleaned = [];
foreach ($links as $token => $record) {
$token = strtolower(trim((string)$token));
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
continue;
}
if (!is_array($record)) {
continue;
}
$folder = trim((string)($record['folder'] ?? 'root'));
if ($folder === '' || strtolower($folder) === 'root') {
$folder = 'root';
} elseif (!preg_match(REGEX_FOLDER_NAME, $folder)) {
continue;
}
$file = basename(trim((string)($record['file'] ?? '')));
if ($file === '' || !preg_match(REGEX_FILE_NAME, $file)) {
continue;
}
$expiresAt = isset($record['expiresAt']) ? (int)$record['expiresAt'] : 0;
if ($expiresAt > 0 && $expiresAt <= $now) {
continue;
}
$sourceId = trim((string)($record['sourceId'] ?? ''));
if ($sourceId !== '' && !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
$sourceId = '';
}
$createdBy = trim((string)($record['createdBy'] ?? ''));
$createdBy = preg_replace('/[\x00-\x1F\x7F]/', '', $createdBy);
$createdAt = isset($record['createdAt']) ? (int)$record['createdAt'] : $now;
if ($createdAt <= 0) {
$createdAt = $now;
}
$cleanRecord = [
'folder' => $folder,
'file' => $file,
'path' => ($folder === 'root') ? $file : ($folder . '/' . $file),
'sourceId' => $sourceId,
'createdBy' => is_string($createdBy) ? $createdBy : '',
'createdAt' => $createdAt,
];
if ($expiresAt > 0) {
$cleanRecord['expiresAt'] = $expiresAt;
}
$cleaned[$token] = $cleanRecord;
}
return $cleaned;
}
public static function createAuthFileLink(
string $folder,
string $file,
string $sourceId = '',
string $createdBy = '',
?int $expiresAt = null
): array {
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ['error' => 'Invalid folder name.'];
}
$file = basename(trim($file));
if (!preg_match(REGEX_FILE_NAME, $file)) {
return ['error' => 'Invalid file name.'];
}
$sourceId = trim($sourceId);
if ($sourceId !== '' && !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
$sourceId = '';
}
$expiresAtInt = $expiresAt ? (int)$expiresAt : 0;
if ($expiresAtInt < 0) {
$expiresAtInt = 0;
}
$createdBy = trim($createdBy);
$createdBy = preg_replace('/[\x00-\x1F\x7F]/', '', $createdBy);
$createdAt = time();
$token = bin2hex(random_bytes(32));
$path = self::authFileLinksPathForMetaRoot(self::metaRoot());
$links = self::readAuthFileLinks($path);
$links = self::cleanAuthFileLinks($links, $createdAt);
while (isset($links[$token])) {
$token = bin2hex(random_bytes(32));
}
$record = [
'folder' => $folder,
'file' => $file,
'path' => ($folder === 'root') ? $file : ($folder . '/' . $file),
'sourceId' => $sourceId,
'createdBy' => is_string($createdBy) ? $createdBy : '',
'createdAt' => $createdAt,
];
if ($expiresAtInt > 0) {
$record['expiresAt'] = $expiresAtInt;
}
$links[$token] = $record;
if (file_put_contents($path, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
return ['error' => 'Could not save auth file link.'];
}
return [
'token' => $token,
'expiresAt' => $expiresAtInt > 0 ? $expiresAtInt : null,
];
}
public static function getAuthFileLinkRecord(string $token): ?array
{
$token = strtolower(trim($token));
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
$now = time();
$readRecord = function (string $metaRoot, string $fallbackSourceId) use ($token, $now): ?array {
$path = self::authFileLinksPathForMetaRoot($metaRoot);
if (!is_file($path)) {
return null;
}
$links = self::readAuthFileLinks($path);
$cleaned = self::cleanAuthFileLinks($links, $now);
if ($cleaned !== $links) {
@file_put_contents($path, json_encode($cleaned, JSON_PRETTY_PRINT), LOCK_EX);
}
if (!isset($cleaned[$token]) || !is_array($cleaned[$token])) {
return null;
}
$record = $cleaned[$token];
$recordSourceId = trim((string)($record['sourceId'] ?? ''));
if ($recordSourceId === '') {
$record['sourceId'] = $fallbackSourceId;
}
return $record;
};
$currentId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';
$record = $readRecord(self::metaRoot(), $currentId);
if ($record) {
return $record;
}
if (!class_exists('SourceContext') || !SourceContext::sourcesEnabled()) {
return null;
}
$sources = SourceContext::listAllSources();
foreach ($sources as $src) {
if (isset($src['enabled']) && !$src['enabled']) {
continue;
}
$id = (string)($src['id'] ?? '');
if ($id === '' || $id === $currentId) {
continue;
}
$record = $readRecord(SourceContext::metaRootForId($id), $id);
if ($record) {
return $record;
}
}
return null;
}
/**
* Retrieves the share record for a given token.
*
@@ -2827,7 +3020,7 @@ class FileModel
* @return array Returns an associative array with keys "token" and "expires" on success,
* or "error" on failure.
*/
public static function createShareLink($folder, $file, $expirationSeconds = 3600, $password = "")
public static function createShareLink($folder, $file, $expirationSeconds = 3600, $password = "", string $createdBy = "")
{
try {
if (FolderCrypto::isEncryptedOrAncestor((string)$folder)) {
@@ -2875,12 +3068,17 @@ class FileModel
}
}
$createdBy = trim($createdBy);
$createdBy = preg_replace('/[\x00-\x1F\x7F]/', '', $createdBy);
// Add new share record.
$shareLinks[$token] = [
"folder" => $folder,
"file" => $file,
"expires" => $expires,
"password" => $hashedPassword
"password" => $hashedPassword,
"createdBy" => is_string($createdBy) ? $createdBy : '',
"createdAt" => time(),
];
// Save the updated share links.
+198 -12
View File
@@ -2251,6 +2251,104 @@ class FolderModel
return $folderInfoList;
}
private static function boolFromMixed($value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return ((int)$value) !== 0;
}
$s = strtolower(trim((string)$value));
return in_array($s, ['1', 'true', 'yes', 'on'], true);
}
private static function intFromMixed($value, int $min = 0, int $max = 0): int
{
if (!is_numeric($value)) {
return $min;
}
$n = (int)$value;
if ($n < $min) {
$n = $min;
}
if ($max > 0 && $n > $max) {
$n = $max;
}
return $n;
}
private static function normalizeShareModeValue($mode, bool $hideListing): string
{
$m = strtolower(trim((string)$mode));
if ($m === 'drop' || $hideListing) {
return 'drop';
}
return 'browse';
}
private static function normalizeShareAllowedTypes($raw): array
{
$items = [];
if (is_string($raw)) {
$parts = preg_split('/[\s,;]+/', $raw) ?: [];
$items = $parts;
} elseif (is_array($raw)) {
$items = $raw;
}
$out = [];
foreach ($items as $it) {
$ext = strtolower(trim((string)$it));
$ext = ltrim($ext, '.');
if ($ext === '') {
continue;
}
if (!preg_match('/^[a-z0-9][a-z0-9._+-]{0,31}$/', $ext)) {
continue;
}
$out[$ext] = $ext;
}
return array_values($out);
}
private static function normalizeShareFolderRecord(array $record): array
{
$hideListing = self::boolFromMixed($record['hideListing'] ?? false);
$mode = self::normalizeShareModeValue($record['mode'] ?? '', $hideListing);
$allowUpload = self::boolFromMixed($record['allowUpload'] ?? 0) ? 1 : 0;
if ($mode === 'drop') {
$allowUpload = 1;
$hideListing = true;
}
$record['mode'] = $mode;
$record['allowUpload'] = $allowUpload;
$record['hideListing'] = $hideListing ? 1 : 0;
$record['allowSubfolders'] = self::boolFromMixed($record['allowSubfolders'] ?? 0) ? 1 : 0;
$record['preserveFolderStructure'] = self::boolFromMixed($record['preserveFolderStructure'] ?? 1) ? 1 : 0;
$record['maxFileSizeMb'] = self::intFromMixed($record['maxFileSizeMb'] ?? 0, 0, 102400);
$record['dailyFileLimit'] = self::intFromMixed($record['dailyFileLimit'] ?? 0, 0, 2000000);
$record['maxTotalMbPerDay'] = self::intFromMixed($record['maxTotalMbPerDay'] ?? 0, 0, 2000000);
$record['allowedTypes'] = self::normalizeShareAllowedTypes($record['allowedTypes'] ?? []);
$createdBy = trim((string)($record['createdBy'] ?? ($record['user'] ?? ($record['username'] ?? ''))));
$createdBy = preg_replace('/[\x00-\x1F\x7F]/', '', $createdBy);
if (is_string($createdBy) && $createdBy !== '') {
$record['createdBy'] = $createdBy;
}
if (isset($record['createdAt']) && is_numeric($record['createdAt'])) {
$record['createdAt'] = max(0, (int)$record['createdAt']);
}
return $record;
}
private static function isShareDropMode(array $record): bool
{
$normalized = self::normalizeShareFolderRecord($record);
return ($normalized['mode'] ?? 'browse') === 'drop' || !empty($normalized['hideListing']);
}
private static function findShareFolderRecord(string $token): ?array
{
$token = (string)$token;
@@ -2262,7 +2360,11 @@ class FolderModel
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return null;
}
return $shareLinks[$token];
$rec = $shareLinks[$token];
if (!is_array($rec)) {
return null;
}
return self::normalizeShareFolderRecord($rec);
};
$currentId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';
@@ -2431,7 +2533,7 @@ class FolderModel
return array_merge($folders, $files);
}
private static function resolveSharedFolderContext(string $token, ?string $providedPass, string $subPath = ''): array
private static function resolveSharedFolderContext(string $token, ?string $providedPass, string $subPath = '', bool $includeEntries = true): array
{
$record = self::findShareFolderRecord($token);
if (!$record) {
@@ -2461,6 +2563,7 @@ class FolderModel
}
$allowSubfolders = !empty($record['allowSubfolders']);
$hideListing = !empty($record['hideListing']) || self::isShareDropMode($record);
[$normalizedSubPath, $pathErr] = self::normalizeShareSubPath($subPath);
if ($pathErr) {
return ["error" => $pathErr];
@@ -2491,11 +2594,14 @@ class FolderModel
return ["error" => "Shared folder not found."];
}
$allEntries = self::listSharedFolderEntries($storage, $realFolderPath);
if (!$allowSubfolders) {
$allEntries = array_values(array_filter($allEntries, function ($entry) {
return (($entry['type'] ?? '') !== 'folder');
}));
$allEntries = [];
if ($includeEntries && !$hideListing) {
$allEntries = self::listSharedFolderEntries($storage, $realFolderPath);
if (!$allowSubfolders) {
$allEntries = array_values(array_filter($allEntries, function ($entry) {
return (($entry['type'] ?? '') !== 'folder');
}));
}
}
return [
@@ -2506,6 +2612,9 @@ class FolderModel
"realFolderPath" => $realFolderPath,
"entries" => $allEntries,
"allowSubfolders" => $allowSubfolders ? 1 : 0,
"mode" => (string)($record['mode'] ?? 'browse'),
"hideListing" => $hideListing ? 1 : 0,
"preserveFolderStructure" => !empty($record['preserveFolderStructure']) ? 1 : 0,
];
}
@@ -2523,6 +2632,15 @@ class FolderModel
if (isset($ctx['error']) || isset($ctx['needs_password'])) {
return $ctx;
}
if (!empty($ctx['hideListing'])) {
$ctx['entries'] = [];
$ctx['currentPage'] = 1;
$ctx['totalPages'] = 1;
$ctx['totalEntries'] = 0;
return $ctx;
}
$allEntries = $ctx['entries'] ?? [];
$totalEntries = count($allEntries);
@@ -2538,11 +2656,30 @@ class FolderModel
return $ctx;
}
public static function getSharedUploadContext(string $token, ?string $providedPass, string $subPath = ''): array
{
$ctx = self::resolveSharedFolderContext($token, $providedPass, $subPath, false);
if (isset($ctx['error']) || isset($ctx['needs_password'])) {
return $ctx;
}
$record = is_array($ctx['record'] ?? null) ? $ctx['record'] : [];
if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) {
return ['error' => 'File uploads are not allowed for this share.'];
}
return $ctx;
}
/**
* Creates a share link for a folder.
*/
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0, int $allowSubfolders = 0): array
{
public static function createShareFolderLink(
string $folder,
int $expirationSeconds = 3600,
string $password = "",
int $allowUpload = 0,
int $allowSubfolders = 0,
array $options = []
): array {
try {
if (FolderCrypto::isEncryptedOrAncestor($folder)) {
return ["error" => "Sharing is disabled inside encrypted folders."];
@@ -2564,6 +2701,32 @@ class FolderModel
return ["error" => "Could not generate token."];
}
$modeRaw = $options['mode'] ?? '';
if (($modeRaw === '' || $modeRaw === null) && !empty($options['fileDrop'])) {
$modeRaw = 'drop';
}
$mode = self::normalizeShareModeValue($modeRaw, false);
$hideListing = array_key_exists('hideListing', $options)
? self::boolFromMixed($options['hideListing'])
: ($mode === 'drop');
if ($mode === 'drop') {
$allowUpload = 1;
$hideListing = true;
}
$preserveFolderStructure = array_key_exists('preserveFolderStructure', $options)
? self::boolFromMixed($options['preserveFolderStructure'])
: true;
$maxFileSizeMb = self::intFromMixed($options['maxFileSizeMb'] ?? 0, 0, 102400);
$dailyFileLimit = self::intFromMixed($options['dailyFileLimit'] ?? 0, 0, 2000000);
$maxTotalMbPerDay = self::intFromMixed($options['maxTotalMbPerDay'] ?? 0, 0, 2000000);
$allowedTypes = self::normalizeShareAllowedTypes($options['allowedTypes'] ?? []);
$createdBy = trim((string)($options['createdBy'] ?? ''));
$createdBy = preg_replace('/[\x00-\x1F\x7F]/', '', $createdBy);
$createdAt = isset($options['createdAt']) && is_numeric($options['createdAt'])
? max(0, (int)$options['createdAt'])
: time();
$expires = time() + max(1, $expirationSeconds);
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
@@ -2585,7 +2748,16 @@ class FolderModel
"expires" => $expires,
"password" => $hashedPassword,
"allowUpload" => $allowUpload ? 1 : 0,
"allowSubfolders" => $allowSubfolders ? 1 : 0
"allowSubfolders" => $allowSubfolders ? 1 : 0,
"mode" => $mode,
"hideListing" => $hideListing ? 1 : 0,
"preserveFolderStructure" => $preserveFolderStructure ? 1 : 0,
"maxFileSizeMb" => $maxFileSizeMb,
"allowedTypes" => $allowedTypes,
"dailyFileLimit" => $dailyFileLimit,
"maxTotalMbPerDay" => $maxTotalMbPerDay,
"createdBy" => is_string($createdBy) ? $createdBy : '',
"createdAt" => $createdAt,
];
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
@@ -2605,7 +2777,7 @@ class FolderModel
$link = $baseUrl . fr_with_base_path("/api/folder/shareFolder.php?token=" . urlencode($token));
}
return ["token" => $token, "expires" => $expires, "link" => $link];
return ["token" => $token, "expires" => $expires, "link" => $link, "mode" => $mode];
}
/**
@@ -2629,6 +2801,10 @@ class FolderModel
return ["error" => "Invalid password."];
}
if (self::isShareDropMode($record)) {
return ["error" => "Downloads are disabled for this upload-only share."];
}
// Encrypted folders/descendants: shared access is blocked (v1).
$folderKey = trim((string)($record['folder'] ?? ''), "/\\ ");
$folderKey = ($folderKey === '' ? 'root' : $folderKey);
@@ -2905,7 +3081,17 @@ class FolderModel
return [];
}
$links = json_decode(file_get_contents($shareFile), true);
return is_array($links) ? $links : [];
if (!is_array($links)) {
return [];
}
$out = [];
foreach ($links as $token => $record) {
if (!is_array($record)) {
continue;
}
$out[(string)$token] = self::normalizeShareFolderRecord($record);
}
return $out;
}
public static function deleteShareFolderLink(string $token): bool
@@ -246,6 +246,37 @@ class FileController
return false;
}
private function enforceSingleFileReadAccess(string $folder, string $file, string $username, array $perms): ?string
{
$ignoreOwnership = $this->isAdmin($perms)
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false))
|| ACL::isOwner($username, $perms, $folder)
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
$fullView = $ignoreOwnership
|| ACL::canRead($username, $perms, $folder)
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
$ownGrant = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
if (!$fullView && !$ownGrant) {
return 'Forbidden: no view access to this folder.';
}
$scopeNeed = $fullView ? 'read' : 'read_own';
$scopeErr = $this->enforceFolderScope($folder, $username, $perms, $scopeNeed);
if ($scopeErr) {
return $scopeErr;
}
if ($ownGrant) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
return 'Forbidden: you are not the owner of this file.';
}
}
return null;
}
/**
* Enforce per-folder scope when the account is in "folder-only" mode.
* $need: 'read' (default) | 'write' | 'manage' | 'share' | 'read_own'
@@ -4291,6 +4322,210 @@ class FileController
exit;
}
public function createAuthFileLink(): void
{
$this->jsonStart();
try {
if (!$this->requireAuth()) {
return;
}
if (!$this->checkCsrf()) {
return;
}
$input = $this->readJsonBody();
if (!is_array($input) || !$input) {
$this->jsonOut(['error' => 'Invalid input.'], 400);
return;
}
$folder = $this->normalizeFolder($input['folder'] ?? '');
$file = basename((string)($input['file'] ?? ''));
if (!$this->validFolder($folder)) {
$this->jsonOut(['error' => 'Invalid folder name.'], 400);
return;
}
if (!$this->validFile($file)) {
$this->jsonOut(['error' => 'Invalid file name.'], 400);
return;
}
$rawSourceId = trim((string)($input['sourceId'] ?? ''));
$sourceId = $this->normalizeSourceId($rawSourceId);
if ($rawSourceId !== '' && $sourceId === '') {
$this->jsonOut(['error' => 'Invalid source id.'], 400);
return;
}
$expiresAt = null;
if (isset($input['expiresAt'])) {
$rawExpiresAt = (int)$input['expiresAt'];
if ($rawExpiresAt > 0) {
$expiresAt = $rawExpiresAt;
}
} elseif (isset($input['expiresInSeconds'])) {
$expiresIn = (int)$input['expiresInSeconds'];
if ($expiresIn > 0) {
$expiresIn = min($expiresIn, 365 * 86400);
$expiresAt = time() + $expiresIn;
}
}
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
$runner = function () use ($folder, $file, $username, $perms, $expiresAt): void {
$accessErr = $this->enforceSingleFileReadAccess($folder, $file, $username, $perms);
if ($accessErr) {
$this->jsonOut(['error' => $accessErr], 403);
return;
}
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
$status = in_array($downloadInfo['error'], ['File not found.', 'Access forbidden.'], true) ? 404 : 400;
$this->jsonOut(['error' => $downloadInfo['error']], $status);
return;
}
$effectiveSourceId = '';
if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
$effectiveSourceId = SourceContext::getActiveId();
}
$result = FileModel::createAuthFileLink(
$folder,
$file,
$effectiveSourceId,
(string)$username,
$expiresAt
);
if (isset($result['error'])) {
$this->jsonOut(['error' => $result['error']], 500);
return;
}
$token = (string)($result['token'] ?? '');
if ($token === '') {
$this->jsonOut(['error' => 'Could not create file link.'], 500);
return;
}
AuditHook::log('file.link.create', [
'user' => $username,
'folder' => $folder,
'path' => ($folder === 'root') ? $file : ($folder . '/' . $file),
'meta' => [
'tokenHash' => substr(hash('sha256', $token), 0, 24),
'sourceId' => $effectiveSourceId,
],
]);
$payload = [
'ok' => true,
'token' => $token,
'url' => fr_with_base_path('/index.html?fileLink=' . rawurlencode($token)),
'sourceId' => $effectiveSourceId,
];
if (isset($result['expiresAt']) && !is_null($result['expiresAt'])) {
$payload['expiresAt'] = (int)$result['expiresAt'];
}
$this->jsonOut($payload, 200);
};
if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
$info = SourceContext::getSourceById($sourceId);
if (!$info) {
$this->jsonOut(['error' => 'Invalid source id.'], 400);
return;
}
if (empty($info['enabled'])) {
$this->jsonOut(['error' => 'Source is disabled.'], 403);
return;
}
$this->withSourceContext($sourceId, $runner, false);
return;
}
$runner();
} catch (Throwable $e) {
error_log('FileController::createAuthFileLink error: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$this->jsonOut(['error' => 'Internal server error while creating file link.'], 500);
} finally {
$this->jsonEnd();
}
}
public function resolveAuthFileLink(): void
{
$this->jsonStart();
try {
if (!$this->requireAuth()) {
return;
}
$token = strtolower(trim((string)($_GET['token'] ?? '')));
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
$this->jsonOut(['error' => 'Invalid token.'], 400);
return;
}
$record = FileModel::getAuthFileLinkRecord($token);
if (!$record || !is_array($record)) {
$this->jsonOut(['error' => 'Link is invalid or expired.'], 404);
return;
}
$folder = $this->normalizeFolder($record['folder'] ?? 'root');
$file = basename((string)($record['file'] ?? ''));
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->jsonOut(['error' => 'Link is invalid or expired.'], 404);
return;
}
$recordSourceId = $this->normalizeSourceId($record['sourceId'] ?? '');
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
$runner = function () use ($folder, $file, $username, $perms, $recordSourceId): void {
$accessErr = $this->enforceSingleFileReadAccess($folder, $file, $username, $perms);
if ($accessErr) {
$this->jsonOut(['error' => 'Forbidden: no view access to this folder.'], 403);
return;
}
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
$this->jsonOut(['error' => 'Link is invalid or expired.'], 404);
return;
}
$this->jsonOut([
'ok' => true,
'folder' => $folder,
'file' => $file,
'sourceId' => $recordSourceId,
], 200);
};
if ($recordSourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
$info = SourceContext::getSourceById($recordSourceId);
if (!$info || empty($info['enabled'])) {
$this->jsonOut(['error' => 'Link is invalid or expired.'], 404);
return;
}
$this->withSourceContext($recordSourceId, $runner, false);
return;
}
$runner();
} catch (Throwable $e) {
error_log('FileController::resolveAuthFileLink error: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$this->jsonOut(['error' => 'Internal server error while resolving file link.'], 500);
} finally {
$this->jsonEnd();
}
}
public function createShareLink()
{
$this->jsonStart();
@@ -4388,7 +4623,7 @@ class FileController
break;
}
$result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password);
$result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password, (string)$username);
if (isset($result['token'])) {
AuditHook::log('share.link.create', [
'user' => $username,
File diff suppressed because it is too large Load Diff