mirror of
https://github.com/error311/FileRise.git
synced 2026-05-09 05:20:32 -05:00
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:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
$fileController = new \FileRise\Http\Controllers\FileController();
|
||||
$fileController->createAuthFileLink();
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
$fileController = new \FileRise\Http\Controllers\FileController();
|
||||
$fileController->resolveAuthFileLink();
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 || "")}"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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">×</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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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">×</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">×</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")}…</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")}…</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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user