-
${t("set_expiration")}
-
+
+
${t("share_folder_and_request_helper")}
-
-
-
-
-
- ${t("custom_duration_warning")}
-
+
+
${t("share_mode_heading")}
+
+
+
+
+
+
-
${t("password_optional")}
-
+
+
${t("share_link_settings")}
+
+
-
+
+
+
+
+
+ ${t("custom_duration_warning")}
+
+
-
+
+
+
+
+
+
${t("share_upload_settings")}
+
+
+
${t("allow_subfolders_helper")}
+
-
-
${t("shareable_link")}
-
-
-
`;
@@ -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"));
- });
}
diff --git a/public/js/i18n.js b/public/js/i18n.js
index 8dc6aba..3d18ee1 100644
--- a/public/js/i18n.js
+++ b/public/js/i18n.js
@@ -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",
diff --git a/public/js/main.js b/public/js/main.js
index 657b2d1..98e9d07 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -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();
diff --git a/public/js/sharedDropView.js b/public/js/sharedDropView.js
new file mode 100644
index 0000000..720f21b
--- /dev/null
+++ b/public/js/sharedDropView.js
@@ -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();
+});
diff --git a/public/js/sharedFolderView.js b/public/js/sharedFolderView.js
index 02cd6db..7bbcd4e 100644
--- a/public/js/sharedFolderView.js
+++ b/public/js/sharedFolderView.js
@@ -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);
});
diff --git a/src/FileRise/Domain/FileModel.php b/src/FileRise/Domain/FileModel.php
index d058feb..3f16ad1 100644
--- a/src/FileRise/Domain/FileModel.php
+++ b/src/FileRise/Domain/FileModel.php
@@ -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.
diff --git a/src/FileRise/Domain/FolderModel.php b/src/FileRise/Domain/FolderModel.php
index ccc899d..756db79 100644
--- a/src/FileRise/Domain/FolderModel.php
+++ b/src/FileRise/Domain/FolderModel.php
@@ -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
diff --git a/src/FileRise/Http/Controllers/FileController.php b/src/FileRise/Http/Controllers/FileController.php
index 1d3d823..4c70307 100644
--- a/src/FileRise/Http/Controllers/FileController.php
+++ b/src/FileRise/Http/Controllers/FileController.php
@@ -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,
diff --git a/src/FileRise/Http/Controllers/FolderController.php b/src/FileRise/Http/Controllers/FolderController.php
index 4fe486a..607eee8 100644
--- a/src/FileRise/Http/Controllers/FolderController.php
+++ b/src/FileRise/Http/Controllers/FolderController.php
@@ -32,6 +32,14 @@ require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
class FolderController
{
+ private const SHARE_RATE_WINDOW_SECONDS = 60;
+ private const SHARE_RATE_MAX_REQUESTS_PER_WINDOW = 180;
+ private const SHARE_RATE_MAX_CONCURRENT = 4;
+ private const SHARE_RATE_ACTIVE_TTL_SECONDS = 300;
+ private const SHARE_DAILY_STATE_KEEP_DAYS = 14;
+ private const SHARE_UPLOAD_LOG_MAX_BYTES = 5242880;
+ private const SHARE_UPLOAD_LOG_MAX_FILES = 3;
+
private ?array $jsonBodyOverride = null;
/* -------------------- Session / Header helpers -------------------- */
@@ -60,6 +68,32 @@ class FolderController
return $headers;
}
+ private static function getQueryString(string $key): string
+ {
+ $value = $_GET[$key] ?? '';
+ if (is_array($value)) {
+ return '';
+ }
+ $clean = preg_replace('/[\x00-\x1F\x7F]/', '', trim((string)$value));
+ return is_string($clean) ? $clean : '';
+ }
+
+ private static function getQueryInt(string $key): ?int
+ {
+ $value = $_GET[$key] ?? null;
+ if ($value === null || $value === '') {
+ return null;
+ }
+ if (is_array($value)) {
+ return null;
+ }
+ $raw = trim((string)$value);
+ if (!preg_match('/^-?\d+$/', $raw)) {
+ return null;
+ }
+ return (int)$raw;
+ }
+
private function normalizeSourceId($id): string
{
$id = trim((string)$id);
@@ -96,6 +130,527 @@ class FolderController
return in_array($s, ['1', 'true', 'yes', 'on'], true);
}
+ private static function shareTokenFingerprint(string $token): string
+ {
+ return substr(hash('sha256', $token), 0, 24);
+ }
+
+ private static function defaultSharedAllowedTypes(): array
+ {
+ return [
+ 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx',
+ 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv', 'csv', 'json', 'xml', 'md'
+ ];
+ }
+
+ private static function normalizeSharedAllowedTypes($raw): array
+ {
+ $items = [];
+ if (is_string($raw)) {
+ $items = preg_split('/[\s,;]+/', $raw) ?: [];
+ } elseif (is_array($raw)) {
+ $items = $raw;
+ }
+
+ $out = [];
+ foreach ($items as $item) {
+ $ext = strtolower(trim((string)$item));
+ $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 validateSharedUploadRules(array $record, string $filename, int $sizeBytes): ?string
+ {
+ $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+ if ($ext === 'svg' || $ext === 'svgz') {
+ return 'Upload blocked: SVG files are not allowed in shared folders.';
+ }
+
+ $maxBytes = 50 * 1024 * 1024;
+ $maxFileSizeMb = isset($record['maxFileSizeMb']) && is_numeric($record['maxFileSizeMb'])
+ ? (int)$record['maxFileSizeMb']
+ : 0;
+ if ($maxFileSizeMb > 0) {
+ $maxBytes = $maxFileSizeMb * 1024 * 1024;
+ } else {
+ $adminConfig = AdminModel::getConfig();
+ if (isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize'])) {
+ $cfgBytes = (int)$adminConfig['sharedMaxUploadSize'];
+ if ($cfgBytes > 0) {
+ $maxBytes = $cfgBytes;
+ }
+ }
+ }
+ if ($sizeBytes > 0 && $sizeBytes > $maxBytes) {
+ return 'File size exceeds allowed limit.';
+ }
+
+ $allowedTypes = self::normalizeSharedAllowedTypes($record['allowedTypes'] ?? []);
+ if (empty($allowedTypes)) {
+ $allowedTypes = self::defaultSharedAllowedTypes();
+ }
+ if ($ext === '' || !in_array($ext, $allowedTypes, true)) {
+ return 'File type not allowed.';
+ }
+
+ return null;
+ }
+
+ private static function getShareStateDir(): string
+ {
+ $metaRoot = class_exists('SourceContext')
+ ? SourceContext::metaRoot()
+ : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
+ $dir = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'share_upload_state';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0775, true);
+ }
+ return $dir;
+ }
+
+ private static function withLockedJsonState(string $path, callable $mutator): void
+ {
+ $fp = @fopen($path, 'c+');
+ if ($fp === false) {
+ return;
+ }
+ try {
+ if (!@flock($fp, LOCK_EX)) {
+ return;
+ }
+ $raw = stream_get_contents($fp);
+ $state = [];
+ if (is_string($raw) && trim($raw) !== '') {
+ $decoded = json_decode($raw, true);
+ if (is_array($decoded)) {
+ $state = $decoded;
+ }
+ }
+ $next = $mutator($state);
+ if (!is_array($next)) {
+ $next = $state;
+ }
+ @ftruncate($fp, 0);
+ @rewind($fp);
+ @fwrite($fp, json_encode($next, JSON_PRETTY_PRINT));
+ @fflush($fp);
+ @flock($fp, LOCK_UN);
+ } finally {
+ @fclose($fp);
+ }
+ }
+
+ private static function applySharedUploadRateLimit(string $tokenHash, string $ip): ?array
+ {
+ $dir = self::getShareStateDir();
+ $path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . 'rate_limits.json';
+ $now = time();
+ $windowStart = $now - self::SHARE_RATE_WINDOW_SECONDS;
+ $activeCutoff = $now - self::SHARE_RATE_ACTIVE_TTL_SECONDS;
+ $key = $tokenHash . '|' . $ip;
+ try {
+ $slotId = bin2hex(random_bytes(8));
+ } catch (\Throwable $e) {
+ $slotId = substr(hash('sha256', uniqid('', true)), 0, 16);
+ }
+
+ $decision = ['ok' => true, 'retryAfter' => 0];
+ self::withLockedJsonState($path, function (array $state) use ($key, $slotId, $now, $windowStart, $activeCutoff, &$decision) {
+ $hits = isset($state['hits']) && is_array($state['hits']) ? $state['hits'] : [];
+ $active = isset($state['active']) && is_array($state['active']) ? $state['active'] : [];
+
+ foreach ($hits as $k => $arr) {
+ if (!is_array($arr)) {
+ unset($hits[$k]);
+ continue;
+ }
+ $hits[$k] = array_values(array_filter($arr, function ($ts) use ($windowStart) {
+ return is_numeric($ts) && (int)$ts >= $windowStart;
+ }));
+ if (empty($hits[$k])) {
+ unset($hits[$k]);
+ }
+ }
+ foreach ($active as $k => $slots) {
+ if (!is_array($slots)) {
+ unset($active[$k]);
+ continue;
+ }
+ foreach ($slots as $sid => $ts) {
+ if (!is_numeric($ts) || (int)$ts < $activeCutoff) {
+ unset($slots[$sid]);
+ }
+ }
+ if (empty($slots)) {
+ unset($active[$k]);
+ } else {
+ $active[$k] = $slots;
+ }
+ }
+
+ $keyHits = $hits[$key] ?? [];
+ if (count($keyHits) >= self::SHARE_RATE_MAX_REQUESTS_PER_WINDOW) {
+ $oldest = min(array_map(static fn($v) => (int)$v, $keyHits));
+ $retryAfter = max(1, self::SHARE_RATE_WINDOW_SECONDS - max(0, $now - $oldest));
+ $decision = ['ok' => false, 'retryAfter' => $retryAfter];
+ $state['hits'] = $hits;
+ $state['active'] = $active;
+ return $state;
+ }
+
+ $keyActive = $active[$key] ?? [];
+ if (count($keyActive) >= self::SHARE_RATE_MAX_CONCURRENT) {
+ $decision = ['ok' => false, 'retryAfter' => 1];
+ $state['hits'] = $hits;
+ $state['active'] = $active;
+ return $state;
+ }
+
+ $keyHits[] = $now;
+ $hits[$key] = $keyHits;
+ $keyActive[$slotId] = $now;
+ $active[$key] = $keyActive;
+
+ $state['hits'] = $hits;
+ $state['active'] = $active;
+ return $state;
+ });
+
+ if (empty($decision['ok'])) {
+ return [
+ 'error' => 'Too many upload requests for this link. Please retry shortly.',
+ 'retryAfter' => max(1, (int)($decision['retryAfter'] ?? 1)),
+ ];
+ }
+
+ register_shutdown_function(function () use ($path, $key, $slotId): void {
+ self::withLockedJsonState($path, function (array $state) use ($key, $slotId) {
+ if (!isset($state['active']) || !is_array($state['active'])) {
+ return $state;
+ }
+ if (isset($state['active'][$key]) && is_array($state['active'][$key])) {
+ unset($state['active'][$key][$slotId]);
+ if (empty($state['active'][$key])) {
+ unset($state['active'][$key]);
+ }
+ }
+ return $state;
+ });
+ });
+
+ return null;
+ }
+
+ private static function checkSharedDailyQuota(array $record, string $tokenHash, int $sizeBytes): ?string
+ {
+ $dailyFileLimit = isset($record['dailyFileLimit']) && is_numeric($record['dailyFileLimit'])
+ ? max(0, (int)$record['dailyFileLimit'])
+ : 0;
+ $maxTotalMbPerDay = isset($record['maxTotalMbPerDay']) && is_numeric($record['maxTotalMbPerDay'])
+ ? max(0, (int)$record['maxTotalMbPerDay'])
+ : 0;
+ if ($dailyFileLimit <= 0 && $maxTotalMbPerDay <= 0) {
+ return null;
+ }
+
+ $path = rtrim(self::getShareStateDir(), '/\\') . DIRECTORY_SEPARATOR . 'daily_quota.json';
+ $today = gmdate('Y-m-d');
+ $maxTotalBytes = ($maxTotalMbPerDay > 0) ? ($maxTotalMbPerDay * 1024 * 1024) : 0;
+ $error = null;
+
+ self::withLockedJsonState($path, function (array $state) use ($today, $tokenHash, $dailyFileLimit, $maxTotalBytes, $sizeBytes, &$error) {
+ $tokens = isset($state['tokens']) && is_array($state['tokens']) ? $state['tokens'] : [];
+ $keepDays = self::SHARE_DAILY_STATE_KEEP_DAYS;
+ $cutoff = gmdate('Y-m-d', strtotime('-' . max(1, $keepDays) . ' days'));
+
+ foreach ($tokens as $tk => $bucket) {
+ if (!is_array($bucket)) {
+ unset($tokens[$tk]);
+ continue;
+ }
+ $days = isset($bucket['days']) && is_array($bucket['days']) ? $bucket['days'] : [];
+ foreach ($days as $day => $row) {
+ if (!is_string($day) || $day < $cutoff) {
+ unset($days[$day]);
+ }
+ }
+ if (empty($days)) {
+ unset($tokens[$tk]);
+ } else {
+ $tokens[$tk] = ['days' => $days];
+ }
+ }
+
+ $entry = $tokens[$tokenHash]['days'][$today] ?? ['files' => 0, 'bytes' => 0];
+ $files = is_numeric($entry['files'] ?? null) ? (int)$entry['files'] : 0;
+ $bytes = is_numeric($entry['bytes'] ?? null) ? (int)$entry['bytes'] : 0;
+
+ if ($dailyFileLimit > 0 && ($files + 1) > $dailyFileLimit) {
+ $error = 'Daily upload file limit reached for this share.';
+ } elseif ($maxTotalBytes > 0 && ($bytes + max(0, $sizeBytes)) > $maxTotalBytes) {
+ $error = 'Daily upload size limit reached for this share.';
+ }
+
+ $state['tokens'] = $tokens;
+ return $state;
+ });
+
+ return $error;
+ }
+
+ private static function incrementSharedDailyQuota(string $tokenHash, int $sizeBytes): void
+ {
+ $path = rtrim(self::getShareStateDir(), '/\\') . DIRECTORY_SEPARATOR . 'daily_quota.json';
+ $today = gmdate('Y-m-d');
+ self::withLockedJsonState($path, function (array $state) use ($today, $tokenHash, $sizeBytes) {
+ $tokens = isset($state['tokens']) && is_array($state['tokens']) ? $state['tokens'] : [];
+ $bucket = isset($tokens[$tokenHash]['days'][$today]) && is_array($tokens[$tokenHash]['days'][$today])
+ ? $tokens[$tokenHash]['days'][$today]
+ : ['files' => 0, 'bytes' => 0];
+ $bucket['files'] = (int)($bucket['files'] ?? 0) + 1;
+ $bucket['bytes'] = (int)($bucket['bytes'] ?? 0) + max(0, $sizeBytes);
+
+ if (!isset($tokens[$tokenHash]) || !is_array($tokens[$tokenHash])) {
+ $tokens[$tokenHash] = ['days' => []];
+ }
+ if (!isset($tokens[$tokenHash]['days']) || !is_array($tokens[$tokenHash]['days'])) {
+ $tokens[$tokenHash]['days'] = [];
+ }
+ $tokens[$tokenHash]['days'][$today] = $bucket;
+ $state['tokens'] = $tokens;
+ return $state;
+ });
+ }
+
+ private static function normalizeHeaderServerKey(string $header): string
+ {
+ $key = strtoupper(str_replace('-', '_', trim($header)));
+ if ($key === 'REMOTE_ADDR') {
+ return $key;
+ }
+ if (strpos($key, 'HTTP_') !== 0) {
+ $key = 'HTTP_' . $key;
+ }
+ return $key;
+ }
+
+ private static function trustedProxies(): array
+ {
+ $raw = defined('FR_TRUSTED_PROXIES') ? (string)FR_TRUSTED_PROXIES : '';
+ if ($raw === '') {
+ return [];
+ }
+ $parts = array_map('trim', explode(',', $raw));
+ return array_values(array_filter($parts, static fn($part) => $part !== ''));
+ }
+
+ private static function ipInCidr(string $ip, string $cidr): bool
+ {
+ $cidr = trim($cidr);
+ if ($cidr === '' || strpos($cidr, '/') === false) {
+ return false;
+ }
+ [$subnet, $maskRaw] = explode('/', $cidr, 2);
+ $subnet = trim($subnet);
+ $mask = (int)trim($maskRaw);
+
+ if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($subnet, FILTER_VALIDATE_IP)) {
+ return false;
+ }
+
+ if (strpos($ip, ':') !== false || strpos($subnet, ':') !== false) {
+ if ($mask < 0 || $mask > 128) {
+ return false;
+ }
+ $ipBin = inet_pton($ip);
+ $netBin = inet_pton($subnet);
+ if ($ipBin === false || $netBin === false) {
+ return false;
+ }
+ $bytes = intdiv($mask, 8);
+ $bits = $mask % 8;
+ if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($netBin, 0, $bytes)) {
+ return false;
+ }
+ if ($bits > 0) {
+ $maskByte = (~((1 << (8 - $bits)) - 1)) & 0xFF;
+ if ((ord($ipBin[$bytes]) & $maskByte) !== (ord($netBin[$bytes]) & $maskByte)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if ($mask < 0 || $mask > 32) {
+ return false;
+ }
+ $ipLong = ip2long($ip);
+ $netLong = ip2long($subnet);
+ if ($ipLong === false || $netLong === false) {
+ return false;
+ }
+ $maskLong = $mask === 0 ? 0 : (-1 << (32 - $mask));
+ return (($ipLong & $maskLong) === ($netLong & $maskLong));
+ }
+
+ private static function isTrustedProxy(string $ip, array $trusted): bool
+ {
+ foreach ($trusted as $entry) {
+ $entry = trim((string)$entry);
+ if ($entry === '') {
+ continue;
+ }
+ if (strpos($entry, '/') === false) {
+ if ($ip === $entry) {
+ return true;
+ }
+ continue;
+ }
+ if (self::ipInCidr($ip, $entry)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static function detectSharedClientIp(): string
+ {
+ $remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
+ if ($remote === '') {
+ $remote = '0.0.0.0';
+ }
+
+ $trusted = self::trustedProxies();
+ if (empty($trusted) || !self::isTrustedProxy($remote, $trusted)) {
+ return $remote;
+ }
+
+ $headerName = defined('FR_IP_HEADER') ? (string)FR_IP_HEADER : 'X-Forwarded-For';
+ $serverKey = self::normalizeHeaderServerKey($headerName);
+ $raw = (string)($_SERVER[$serverKey] ?? '');
+ if ($raw !== '') {
+ $parts = explode(',', $raw);
+ foreach ($parts as $part) {
+ $candidate = trim($part);
+ if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) {
+ return $candidate;
+ }
+ }
+ }
+
+ if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
+ $candidate = trim((string)$_SERVER['HTTP_X_REAL_IP']);
+ if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) {
+ return $candidate;
+ }
+ }
+
+ return $remote;
+ }
+
+ private static function rotateJsonLog(string $path, int $maxBytes, int $maxFiles): void
+ {
+ if ($maxBytes <= 0 || $maxFiles <= 1 || !is_file($path)) {
+ return;
+ }
+ $size = @filesize($path);
+ if ($size === false || $size < $maxBytes) {
+ return;
+ }
+
+ $maxRotated = $maxFiles - 1;
+ for ($i = $maxRotated; $i >= 1; $i--) {
+ $src = $path . '.' . $i;
+ if ($i === $maxRotated) {
+ if (is_file($src)) {
+ @unlink($src);
+ }
+ continue;
+ }
+ $dst = $path . '.' . ($i + 1);
+ if (is_file($src)) {
+ @rename($src, $dst);
+ }
+ }
+ @rename($path, $path . '.1');
+ }
+
+ private static function logSharedUploadSubmission(string $tokenHash, string $ip, string $path, int $bytes): void
+ {
+ $metaRoot = class_exists('SourceContext')
+ ? SourceContext::metaRoot()
+ : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
+ $logPath = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'share_upload_submissions.log';
+ self::rotateJsonLog($logPath, self::SHARE_UPLOAD_LOG_MAX_BYTES, self::SHARE_UPLOAD_LOG_MAX_FILES);
+
+ $entry = [
+ 'createdAt' => gmdate('c'),
+ 'tokenHash' => $tokenHash,
+ 'ip' => $ip,
+ 'path' => $path,
+ 'bytes' => max(0, $bytes),
+ 'userAgent' => (string)($_SERVER['HTTP_USER_AGENT'] ?? ''),
+ ];
+
+ @file_put_contents(
+ $logPath,
+ json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
+ FILE_APPEND | LOCK_EX
+ );
+ }
+
+ private static function parseUploadRelativePath(string $raw): array
+ {
+ $raw = rawurldecode((string)$raw);
+ $raw = str_replace('\\', '/', trim($raw));
+ $raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
+ $raw = ltrim($raw, '/');
+ if ($raw === '' || $raw === '.') {
+ return ['', '', null];
+ }
+ if (preg_match('~(^|/)\.\.(?:/|$)~', $raw) || preg_match('~(^|/)\.(?:/|$)~', $raw)) {
+ return ['', '', 'Invalid relative path.'];
+ }
+
+ $file = basename($raw);
+ if ($file === '' || !preg_match(REGEX_FILE_NAME, $file)) {
+ return ['', '', 'Invalid file name.'];
+ }
+
+ $dir = dirname($raw);
+ if ($dir === '.' || $dir === '') {
+ return ['', $file, null];
+ }
+ if (!preg_match(REGEX_FOLDER_NAME, $dir)) {
+ return ['', '', 'Invalid folder name.'];
+ }
+ return [$dir, $file, null];
+ }
+
+ private static function wantsJsonUploadResponse(bool $isChunk): bool
+ {
+ if ($isChunk) {
+ return true;
+ }
+ if (isset($_POST['response']) && strtolower(trim((string)$_POST['response'])) === 'json') {
+ return true;
+ }
+ $accept = strtolower((string)($_SERVER['HTTP_ACCEPT'] ?? ''));
+ if ($accept !== '' && strpos($accept, 'application/json') !== false) {
+ return true;
+ }
+ $xrw = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? ''));
+ return $xrw === 'xmlhttprequest';
+ }
+
private function isAsyncRequested(array $payload): bool
{
return $this->truthy($payload['async'] ?? false)
@@ -1137,12 +1692,9 @@ class FolderController
/* -------------------- API: Download Shared File -------------------- */
public function downloadSharedFile(): void
{
- $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
- $file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING);
- $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
- if ($providedPass === null || $providedPass === false) {
- $providedPass = '';
- }
+ $token = self::getQueryString('token');
+ $file = self::getQueryString('file');
+ $providedPass = self::getQueryString('pass');
$path = (string)($_GET['path'] ?? '');
$inlineRequested = ((string)($_GET['inline'] ?? '') === '1');
@@ -1173,7 +1725,12 @@ class FolderController
exit;
}
if (isset($result['error'])) {
- $code = ($result['error'] === 'Invalid password.') ? 403 : 404;
+ $forbiddenErrors = [
+ 'Invalid password.',
+ 'Password required.',
+ 'Downloads are disabled for this upload-only share.',
+ ];
+ $code = in_array((string)$result['error'], $forbiddenErrors, true) ? 403 : 404;
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => $result['error']]);
@@ -1334,8 +1891,8 @@ class FolderController
/* -------------------- API: Download Shared Folder (ZIP) -------------------- */
public function downloadSharedFolder(): void
{
- $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
- $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
+ $token = self::getQueryString('token');
+ $providedPass = self::getQueryString('pass');
$path = (string)($_GET['path'] ?? '');
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
@@ -1413,6 +1970,9 @@ class FolderController
if (isset($ctx['error'])) {
$renderError(404, (string)$ctx['error']);
}
+ if (!empty($ctx['hideListing']) || (isset($ctx['mode']) && (string)$ctx['mode'] === 'drop')) {
+ $renderError(403, "Downloads are disabled for this upload-only share.");
+ }
$storage = StorageRegistry::getAdapter();
if (!$storage->isLocal()) {
@@ -1602,11 +2162,11 @@ class FolderController
/* -------------------- Public Shared Folder HTML -------------------- */
public function shareFolder(): void
{
- $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
- $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
- $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);
+ $token = self::getQueryString('token');
+ $providedPass = self::getQueryString('pass');
+ $page = self::getQueryInt('page');
$path = (string)($_GET['path'] ?? '');
- if ($page === false || $page < 1) {
+ if ($page === null || $page < 1) {
$page = 1;
}
@@ -1670,42 +2230,69 @@ class FolderController
echo json_encode(["error" => $data['error']]);
exit;
}
- $adminConfig = AdminModel::getConfig();
- $sharedMaxUploadSize = (isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize']))
- ? (int)$adminConfig['sharedMaxUploadSize'] : null;
+ $adminConfig = AdminModel::getConfig();
+ $sharedMaxUploadSize = (isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize']))
+ ? (int)$adminConfig['sharedMaxUploadSize']
+ : null;
$headerTitle = trim((string)($adminConfig['header_title'] ?? 'FileRise'));
if ($headerTitle === '') {
$headerTitle = 'FileRise';
}
- $entries = is_array($data['entries'] ?? null) ? $data['entries'] : [];
+ $record = is_array($data['record'] ?? null) ? $data['record'] : [];
+ $entries = is_array($data['entries'] ?? null) ? $data['entries'] : [];
$currentPage = (int)($data['currentPage'] ?? 1);
- $totalPages = (int)($data['totalPages'] ?? 1);
+ $totalPages = (int)($data['totalPages'] ?? 1);
$totalEntries = (int)($data['totalEntries'] ?? 0);
- $shareRoot = (string)($data['shareRoot'] ?? 'root');
+ $shareRoot = (string)($data['shareRoot'] ?? 'root');
$currentPath = (string)($data['path'] ?? '');
$allowSubfolders = !empty($data['allowSubfolders']);
- $displayName = 'Shared Folder';
- if ($currentPath !== '') {
- $displayName = basename($currentPath);
- } elseif ($shareRoot !== '' && strtolower($shareRoot) !== 'root') {
- $displayName = basename($shareRoot);
+ $allowUpload = isset($record['allowUpload']) && (int)$record['allowUpload'] === 1;
+ $isDropMode = (isset($record['mode']) && (string)$record['mode'] === 'drop')
+ || !empty($record['hideListing'])
+ || !empty($data['hideListing']);
+ if ($isDropMode) {
+ $allowUpload = true;
+ $entries = [];
+ $currentPage = 1;
+ $totalPages = 1;
+ $totalEntries = 0;
}
- $pageTitle = $headerTitle . ' Share';
+ $hideListing = $isDropMode || !empty($data['hideListing']);
+ $preserveFolderStructure = !isset($record['preserveFolderStructure']) || !empty($record['preserveFolderStructure']);
+
+ $displayName = $isDropMode ? 'Upload files' : 'Shared Folder';
+ if (!$isDropMode) {
+ if ($currentPath !== '') {
+ $displayName = basename($currentPath);
+ } elseif ($shareRoot !== '' && strtolower($shareRoot) !== 'root') {
+ $displayName = basename($shareRoot);
+ }
+ }
+ $pageTitle = $headerTitle . ($isDropMode ? ' File Request' : ' Share');
if ($displayName !== '') {
$pageTitle .= ': ' . $displayName;
}
+
$storage = StorageRegistry::getAdapter();
- $canDownloadAll = $storage->isLocal();
- $allowUpload = isset($data['record']['allowUpload']) && (int)$data['record']['allowUpload'] === 1;
+ $canDownloadAll = !$hideListing && $storage->isLocal();
+
+ $effectiveMaxFileSizeMb = (isset($record['maxFileSizeMb']) && is_numeric($record['maxFileSizeMb']) && (int)$record['maxFileSizeMb'] > 0)
+ ? (int)$record['maxFileSizeMb']
+ : (($sharedMaxUploadSize !== null && $sharedMaxUploadSize > 0) ? (int)ceil($sharedMaxUploadSize / (1024 * 1024)) : 0);
+ $allowedTypes = self::normalizeSharedAllowedTypes($record['allowedTypes'] ?? []);
+ $dailyFileLimit = (isset($record['dailyFileLimit']) && is_numeric($record['dailyFileLimit'])) ? (int)$record['dailyFileLimit'] : 0;
+ $maxTotalMbPerDay = (isset($record['maxTotalMbPerDay']) && is_numeric($record['maxTotalMbPerDay'])) ? (int)$record['maxTotalMbPerDay'] : 0;
+
$uploadToken = '';
if ($allowUpload) {
$secret = (string)($GLOBALS['encryptionKey'] ?? '');
if ($secret !== '') {
- $seed = $token . '|' . $currentPath . '|' . $providedPass;
+ $seed = $token . '|' . (string)$providedPass;
$uploadToken = hash_hmac('sha256', $seed, $secret);
}
}
+
$shareBaseUrl = fr_with_base_path('/api/folder/shareFolder.php');
$queryBase = 'token=' . urlencode($token);
if (!empty($providedPass)) {
@@ -1737,42 +2324,47 @@ class FolderController
-
+
+
+
Upload files
+
+ Uploaders can't see existing files
+
+
+
+
+
-
-
-
This folder is empty.
+
+
+
+
This folder is empty.
- 1) : ?>
-
+ 1) : ?>
+
+
@@ -1826,9 +2456,21 @@ class FolderController
'totalEntries' => $totalEntries,
'currentPage' => $currentPage,
'totalPages' => $totalPages,
+ 'mode' => $isDropMode ? 'drop' : 'browse',
+ 'hideListing' => $hideListing ? 1 : 0,
+ 'allowUpload' => $allowUpload ? 1 : 0,
+ 'preserveFolderStructure' => $preserveFolderStructure ? 1 : 0,
+ 'maxFileSizeMb' => $effectiveMaxFileSizeMb,
+ 'allowedTypes' => $allowedTypes,
+ 'dailyFileLimit' => max(0, $dailyFileLimit),
+ 'maxTotalMbPerDay' => max(0, $maxTotalMbPerDay),
], JSON_HEX_TAG | JSON_HEX_AMP); ?>
-
+
+
+
+
+