Files
FileRise/public/js/sharedDropView.js
T
Ryan 75f2d6de69 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
2026-02-19 00:47:53 -05:00

776 lines
25 KiB
JavaScript

// 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();
});