release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth

This commit is contained in:
Ryan
2025-10-31 17:34:25 -04:00
committed by GitHub
parent a18a8df7af
commit d664a2f5d8
21 changed files with 2272 additions and 967 deletions

View File

@@ -10,16 +10,16 @@ const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;
function buildFullGrantsForAllFolders(folders) {
const allTrue = {
view:true, viewOwn:false, manage:true, create:true, upload:true, edit:true,
rename:true, copy:true, move:true, delete:true, extract:true,
shareFile:true, shareFolder:true, share:true
view: true, viewOwn: false, manage: true, create: true, upload: true, edit: true,
rename: true, copy: true, move: true, delete: true, extract: true,
shareFile: true, shareFolder: true, share: true
};
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
}
/* === BEGIN: Folder Access helpers (merged + improved) === */
function qs(scope, sel){ return (scope||document).querySelector(sel); }
function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); }
function qs(scope, sel) { return (scope || document).querySelector(sel); }
function qsa(scope, sel) { return Array.from((scope || document).querySelectorAll(sel)); }
function enforceShareFolderRule(row) {
const manage = qs(row, 'input[data-cap="manage"]');
@@ -37,6 +37,66 @@ function enforceShareFolderRule(row) {
}
}
function wireHeaderTitleLive() {
const input = document.getElementById('headerTitle');
if (!input || input.__live) return;
input.__live = true;
const apply = (val) => {
const title = (val || '').trim() || 'FileRise';
const h1 = document.querySelector('.header-title h1');
if (h1) h1.textContent = title;
document.title = title;
window.headerTitle = val || ''; // preserve raw value user typed
try { localStorage.setItem('headerTitle', title); } catch { }
};
// apply current value immediately + on each keystroke
apply(input.value);
input.addEventListener('input', (e) => apply(e.target.value));
}
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
const type = isSecret ? 'password' : 'text';
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : '';
const replaceBtn = hasValue
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
: '';
const note = hasValue
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
: '';
return `
<div class="form-group">
<label for="${id}">${label}:</label>
<div style="display:flex; gap:8px; align-items:center;">
<input type="${type}" id="${id}" class="form-control" ${disabled} />
${replaceBtn}
</div>
${note}
</div>
`;
}
function wireReplaceButtons(scope = document) {
scope.querySelectorAll('[data-replace-for]').forEach(btn => {
if (btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-replace-for');
const inp = scope.querySelector('#' + id);
if (!inp) return;
inp.disabled = false;
inp.dataset.replace = '1';
inp.placeholder = '';
inp.value = '';
btn.textContent = 'Keep saved value';
btn.removeAttribute('data-replace-for');
btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true });
}, { once: true });
});
}
function onShareFolderToggle(row, checked) {
const manage = qs(row, 'input[data-cap="manage"]');
const viewAll = qs(row, 'input[data-cap="view"]');
@@ -52,14 +112,14 @@ function onShareFileToggle(row, checked) {
const viewAll = qs(row, 'input[data-cap="view"]');
const viewOwn = qs(row, 'input[data-cap="viewOwn"]');
const hasView = !!(viewAll && viewAll.checked);
const hasOwn = !!(viewOwn && viewOwn.checked);
const hasOwn = !!(viewOwn && viewOwn.checked);
if (!hasView && !hasOwn && viewOwn) {
viewOwn.checked = true;
}
}
function onWriteToggle(row, checked) {
const caps = ["create","upload","edit","rename","copy","delete","extract"];
const caps = ["create", "upload", "edit", "rename", "copy", "delete", "extract"];
caps.forEach(c => {
const box = qs(row, `input[data-cap="${c}"]`);
if (box) box.checked = checked;
@@ -426,20 +486,21 @@ export function openAdminPanel() {
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${adminTitle}</h3>
<form id="adminPanelForm">
${[
{ id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: t("header_settings") },
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") }
].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i>
</div>
<div id="${sec.id}Content" class="section-content"></div>
`).join("")}
${[
{ id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: t("header_settings") },
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i>
</div>
<div id="${sec.id}Content" class="section-content"></div>
`).join("")}
<div class="action-row">
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
@@ -453,7 +514,7 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks"]
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
@@ -485,6 +546,7 @@ export function openAdminPanel() {
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div>
`;
wireHeaderTitleLive();
document.getElementById("loginOptionsContent").innerHTML = `
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
@@ -512,16 +574,34 @@ export function openAdminPanel() {
</div>
`;
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
document.getElementById("oidcContent").innerHTML = `
<div class="form-text text-muted" style="margin-top:8px;">
<small>Note: OIDC credentials (Client ID/Secret) will show blank here after saving, but remain unchanged until you explicitly edit and save them.</small>
</div>
<div class="form-group"><label for="oidcProviderUrl">${t("oidc_provider_url")}:</label><input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig?.providerUrl || ""}" /></div>
<div class="form-group"><label for="oidcClientId">${t("oidc_client_id")}:</label><input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig?.clientId || ""}" /></div>
<div class="form-group"><label for="oidcClientSecret">${t("oidc_client_secret")}:</label><input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig?.clientSecret || ""}" /></div>
<div class="form-group"><label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label><input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig?.redirectUri || ""}" /></div>
<div class="form-group"><label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label><input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" /></div>
`;
<div class="form-text text-muted" style="margin-top:8px;">
<small>Client ID/Secret are never shown after saving. A green note indicates a value is saved. Click “Replace” to overwrite.</small>
</div>
<div class="form-group">
<label for="oidcProviderUrl">${t("oidc_provider_url")}:</label>
<input type="text" id="oidcProviderUrl" class="form-control" value="${(window.currentOIDCConfig?.providerUrl || "")}" />
</div>
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })}
${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
<div class="form-group">
<label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label>
<input type="text" id="oidcRedirectUri" class="form-control" value="${(window.currentOIDCConfig?.redirectUri || "")}" />
</div>
<div class="form-group">
<label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label>
<input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
</div>
`;
wireReplaceButtons(document.getElementById("oidcContent"));
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
@@ -545,6 +625,60 @@ export function openAdminPanel() {
}
});
// --- Sponsor (fixed, non-editable) ---
const SPONSOR_GH = "https://github.com/sponsors/error311";
const SPONSOR_KOFI = "https://ko-fi.com/error311";
document.getElementById("sponsorContent").innerHTML = `
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorGitHub">${(typeof tf === 'function' ? tf("github_sponsors_url", "GitHub Sponsors URL") : "GitHub Sponsors URL")}:</label>
<div class="input-group">
<input type="url"
id="sponsorGitHub"
class="form-control"
value="${SPONSOR_GH}"
readonly
data-ignore-dirty="1" />
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">Copy</button>
<a class="btn btn-outline-secondary" id="openSponsorGitHub" target="_blank" rel="noopener">Open</a>
</div>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorKoFi">${(typeof tf === 'function' ? tf("ko_fi_url", "Ko-fi URL") : "Ko-fi URL")}:</label>
<div class="input-group">
<input type="url"
id="sponsorKoFi"
class="form-control"
value="${SPONSOR_KOFI}"
readonly
data-ignore-dirty="1" />
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">Copy</button>
<a class="btn btn-outline-secondary" id="openSponsorKoFi" target="_blank" rel="noopener">Open</a>
</div>
</div>
<small class="text-muted">${(typeof tf === 'function'
? tf("sponsor_note_fixed", "Please consider supporting ongoing development.")
: "Please consider supporting ongoing development.")}</small>
`;
// Wire copy + open (no changes tracked)
const ghInput = document.getElementById("sponsorGitHub");
const kfInput = document.getElementById("sponsorKoFi");
document.getElementById("copySponsorGitHub").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(ghInput.value); } catch { }
showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!");
});
document.getElementById("copySponsorKoFi").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(kfInput.value); } catch { }
showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!");
});
document.getElementById("openSponsorGitHub").href = SPONSOR_GH;
document.getElementById("openSponsorKoFi").href = SPONSOR_KOFI;
const userMgmt = document.getElementById("userManagementContent");
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
window.__userMgmtDelegatedClick = (e) => {
@@ -574,7 +708,11 @@ export function openAdminPanel() {
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
document.getElementById("oidcClientId").value = window.currentOIDCConfig?.clientId || "";
const idEl = document.getElementById("oidcClientId");
const secEl = document.getElementById("oidcClientSecret");
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || "";
wireReplaceButtons(document.getElementById("oidcContent"));
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
@@ -585,57 +723,57 @@ export function openAdminPanel() {
}
function handleSave() {
const dFL = !!document.getElementById("disableFormLogin")?.checked;
const dBA = !!document.getElementById("disableBasicAuth")?.checked;
const dOIDC = !!document.getElementById("disableOIDCLogin")?.checked;
const aBypass = !!document.getElementById("authBypass")?.checked;
const aHeader = (document.getElementById("authHeaderName")?.value || "X-Remote-User").trim();
const eWD = !!document.getElementById("enableWebDAV")?.checked;
const sMax = parseInt(document.getElementById("sharedMaxUploadSize")?.value || "0", 10) || 0;
const nHT = (document.getElementById("headerTitle")?.value || "").trim();
const nOIDC = {
providerUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(),
clientId: (document.getElementById("oidcClientId")?.value || "").trim(),
clientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(),
redirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim()
const payload = {
header_title: document.getElementById("headerTitle")?.value || "",
loginOptions: {
disableFormLogin: document.getElementById("disableFormLogin").checked,
disableBasicAuth: document.getElementById("disableBasicAuth").checked,
disableOIDCLogin: document.getElementById("disableOIDCLogin").checked,
authBypass: document.getElementById("authBypass").checked,
authHeaderName: document.getElementById("authHeaderName").value.trim() || "X-Remote-User",
},
enableWebDAV: document.getElementById("enableWebDAV").checked,
sharedMaxUploadSize: parseInt(document.getElementById("sharedMaxUploadSize").value || "0", 10) || 0,
oidc: {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
redirectUri: document.getElementById("oidcRedirectUri").value.trim(),
// clientId/clientSecret: only include when replacing
},
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(),
};
const gURL = (document.getElementById("globalOtpauthUrl")?.value || "").trim();
if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
showToast(t("at_least_one_login_method"));
return;
const idEl = document.getElementById("oidcClientId");
const scEl = document.getElementById("oidcClientSecret");
if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') {
payload.oidc.clientId = idEl.value.trim();
}
if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') {
payload.oidc.clientSecret = scEl.value.trim();
}
sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: nHT,
oidc: nOIDC,
loginOptions: {
disableFormLogin: dFL,
disableBasicAuth: dBA,
disableOIDCLogin: dOIDC,
authBypass: aBypass,
authHeaderName: aHeader
fetch('/api/admin/updateConfig.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
},
enableWebDAV: eWD,
sharedMaxUploadSize: sMax,
globalOtpauthUrl: gURL
}, { "X-CSRF-Token": window.csrfToken })
.then(res => {
if (res.success) {
showToast(t("settings_updated_successfully"), "success");
captureInitialAdminConfig();
closeAdminPanel();
loadAdminConfigFunc();
} else {
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
}
}).catch(() => {/*noop*/ });
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(j => {
if (j.error) { showToast('Error: ' + j.error); return; }
showToast('Settings saved.');
closeAdminPanel();
})
.catch(() => showToast('Save failed.'));
}
export async function closeAdminPanel() {
if (hasUnsavedChanges()) {
const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
if (!ok) return;
//const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
//if (!ok) return;
}
const m = document.getElementById("adminPanelModal");
if (m) m.style.display = "none";
@@ -645,29 +783,29 @@ export async function closeAdminPanel() {
New: Folder Access (ACL) UI
=========================== */
let __allFoldersCache = null;
let __allFoldersCache = null;
async function getAllFolders(force = false) {
if (!force && __allFoldersCache) return __allFoldersCache.slice();
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' }
});
const data = await safeJson(res).catch(() => []);
const list = Array.isArray(data)
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
: [];
const hidden = new Set(['profile_pics', 'trash']);
const cleaned = list
.filter(f => f && !hidden.has(f.toLowerCase()))
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
__allFoldersCache = cleaned;
return cleaned.slice();
}
async function getAllFolders(force = false) {
if (!force && __allFoldersCache) return __allFoldersCache.slice();
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' }
});
const data = await safeJson(res).catch(() => []);
const list = Array.isArray(data)
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
: [];
const hidden = new Set(['profile_pics', 'trash']);
const cleaned = list
.filter(f => f && !hidden.has(f.toLowerCase()))
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
__allFoldersCache = cleaned;
return cleaned.slice();
}
async function getUserGrants(username) {
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
@@ -683,7 +821,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
// toolbar
const toolbar = document.createElement('div');
toolbar.className = 'folder-access-toolbar';
toolbar.innerHTML = `
toolbar.innerHTML = `
<input type="text" class="form-control" style="max-width:220px;"
placeholder="${tf('search_folders', 'Search folders')}" />
@@ -717,8 +855,8 @@ toolbar.innerHTML = `
const headerHtml = `
<div class="folder-access-header">
<div class="folder-cell" title="${tf('folder_help','Folder path within FileRise')}">
${tf('folder','Folder')}
<div class="folder-cell" title="${tf('folder_help', 'Folder path within FileRise')}">
${tf('folder', 'Folder')}
</div>
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">
${tf('view_all', 'View (all)')}
@@ -802,7 +940,7 @@ toolbar.innerHTML = `
}
function refreshInheritance() {
const rows = qsa(list, '.folder-access-row').sort((a,b)=> (a.dataset.folder||'').length - (b.dataset.folder||'').length);
const rows = qsa(list, '.folder-access-row').sort((a, b) => (a.dataset.folder || '').length - (b.dataset.folder || '').length);
const managedPrefixes = new Set();
rows.forEach(row => {
const folder = row.dataset.folder || "";
@@ -813,13 +951,13 @@ toolbar.innerHTML = `
if (p && folder !== p && folder.startsWith(p + '/')) { inheritedFrom = p; break; }
}
if (inheritedFrom) {
const v = qs(row,'input[data-cap="view"]');
const w = qs(row,'input[data-cap="write"]');
const vo= qs(row,'input[data-cap="viewOwn"]');
const v = qs(row, 'input[data-cap="view"]');
const w = qs(row, 'input[data-cap="write"]');
const vo = qs(row, 'input[data-cap="viewOwn"]');
if (v) v.checked = true;
if (w) w.checked = true;
if (vo) { vo.checked = false; vo.disabled = true; }
['create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder']
['create', 'upload', 'edit', 'rename', 'copy', 'delete', 'extract', 'shareFile', 'shareFolder']
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
setRowDisabled(row, true);
const tag = row.querySelector('.inherited-tag');
@@ -828,8 +966,8 @@ toolbar.innerHTML = `
setRowDisabled(row, false);
}
enforceShareFolderRule(row);
const cbView = qs(row,'input[data-cap="view"]');
const cbViewOwn = qs(row,'input[data-cap="viewOwn"]');
const cbView = qs(row, 'input[data-cap="view"]');
const cbViewOwn = qs(row, 'input[data-cap="viewOwn"]');
if (cbView && cbViewOwn) {
if (cbView.checked) {
cbViewOwn.checked = false;
@@ -847,8 +985,8 @@ toolbar.innerHTML = `
if (!checked && (which === 'view' || which === 'viewOwn')) {
qsa(row, 'input[type="checkbox"]').forEach(cb => cb.checked = false);
}
const cbView = qs(row,'input[data-cap="view"]');
const cbVO = qs(row,'input[data-cap="viewOwn"]');
const cbView = qs(row, 'input[data-cap="view"]');
const cbVO = qs(row, 'input[data-cap="viewOwn"]');
if (cbView && cbVO) {
if (cbView.checked) {
cbVO.checked = false;
@@ -863,19 +1001,19 @@ toolbar.innerHTML = `
}
function wireRow(row) {
const cbView = row.querySelector('input[data-cap="view"]');
const cbView = row.querySelector('input[data-cap="view"]');
const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]');
const cbWrite = row.querySelector('input[data-cap="write"]');
const cbManage = row.querySelector('input[data-cap="manage"]');
const cbCreate = row.querySelector('input[data-cap="create"]');
const cbUpload = row.querySelector('input[data-cap="upload"]');
const cbEdit = row.querySelector('input[data-cap="edit"]');
const cbRename = row.querySelector('input[data-cap="rename"]');
const cbCopy = row.querySelector('input[data-cap="copy"]');
const cbMove = row.querySelector('input[data-cap="move"]');
const cbDelete = row.querySelector('input[data-cap="delete"]');
const cbWrite = row.querySelector('input[data-cap="write"]');
const cbManage = row.querySelector('input[data-cap="manage"]');
const cbCreate = row.querySelector('input[data-cap="create"]');
const cbUpload = row.querySelector('input[data-cap="upload"]');
const cbEdit = row.querySelector('input[data-cap="edit"]');
const cbRename = row.querySelector('input[data-cap="rename"]');
const cbCopy = row.querySelector('input[data-cap="copy"]');
const cbMove = row.querySelector('input[data-cap="move"]');
const cbDelete = row.querySelector('input[data-cap="delete"]');
const cbExtract = row.querySelector('input[data-cap="extract"]');
const cbShareF = row.querySelector('input[data-cap="shareFile"]');
const cbShareF = row.querySelector('input[data-cap="shareFile"]');
const cbShareFo = row.querySelector('input[data-cap="shareFolder"]');
const granular = [cbCreate, cbUpload, cbEdit, cbRename, cbCopy, cbMove, cbDelete, cbExtract];
@@ -885,7 +1023,7 @@ toolbar.innerHTML = `
if (cbView) cbView.checked = true;
if (cbWrite) cbWrite.checked = true;
granular.forEach(cb => { if (cb) cb.checked = true; });
if (cbShareF) cbShareF.checked = true;
if (cbShareF) cbShareF.checked = true;
if (cbShareFo && !cbShareFo.disabled) cbShareFo.checked = true;
}
};
@@ -919,7 +1057,7 @@ toolbar.innerHTML = `
const w = r.querySelector('input[data-cap="write"]');
const vo = r.querySelector('input[data-cap="viewOwn"]');
const boxes = [
'create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder'
'create', 'upload', 'edit', 'rename', 'copy', 'delete', 'extract', 'shareFile', 'shareFolder'
].map(c => r.querySelector(`input[data-cap="${c}"]`));
if (m) m.checked = checked;
if (v) v.checked = checked;
@@ -932,7 +1070,7 @@ toolbar.innerHTML = `
};
if (cbManage) cbManage.addEventListener('change', () => { applyManage(); onShareFile(); cascadeManage(cbManage.checked); });
if (cbWrite) cbWrite.addEventListener('change', applyWrite);
if (cbWrite) cbWrite.addEventListener('change', applyWrite);
granular.forEach(cb => { if (cb) cb.addEventListener('change', () => { syncWriteFromGranular(); }); });
if (cbView) cbView.addEventListener('change', () => { setFromViewChange(row, 'view', cbView.checked); refreshInheritance(); });
if (cbViewOwn) cbViewOwn.addEventListener('change', () => { setFromViewChange(row, 'viewOwn', cbViewOwn.checked); refreshInheritance(); });
@@ -1004,18 +1142,18 @@ function collectGrantsFrom(container) {
const folder = row.dataset.folder || row.getAttribute('data-folder');
if (!folder) return;
const g = {
view: get(row, 'input[data-cap="view"]'),
viewOwn: get(row, 'input[data-cap="viewOwn"]'),
manage: get(row, 'input[data-cap="manage"]'),
create: get(row, 'input[data-cap="create"]'),
upload: get(row, 'input[data-cap="upload"]'),
edit: get(row, 'input[data-cap="edit"]'),
rename: get(row, 'input[data-cap="rename"]'),
copy: get(row, 'input[data-cap="copy"]'),
move: get(row, 'input[data-cap="move"]'),
delete: get(row, 'input[data-cap="delete"]'),
extract: get(row, 'input[data-cap="extract"]'),
shareFile: get(row, 'input[data-cap="shareFile"]'),
view: get(row, 'input[data-cap="view"]'),
viewOwn: get(row, 'input[data-cap="viewOwn"]'),
manage: get(row, 'input[data-cap="manage"]'),
create: get(row, 'input[data-cap="create"]'),
upload: get(row, 'input[data-cap="upload"]'),
edit: get(row, 'input[data-cap="edit"]'),
rename: get(row, 'input[data-cap="rename"]'),
copy: get(row, 'input[data-cap="copy"]'),
move: get(row, 'input[data-cap="move"]'),
delete: get(row, 'input[data-cap="delete"]'),
extract: get(row, 'input[data-cap="extract"]'),
shareFile: get(row, 'input[data-cap="shareFile"]'),
shareFolder: get(row, 'input[data-cap="shareFolder"]')
};
g.share = !!(g.shareFile || g.shareFolder);
@@ -1074,16 +1212,16 @@ export function openUserPermissionsModal() {
});
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
const changes = [];
rows.forEach(row => {
if (row.getAttribute("data-admin") === "1") return; // skip admins
const username = String(row.getAttribute("data-username") || "").trim();
if (!username) return;
const grantsBox = row.querySelector(".folder-grants-box");
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
const grants = collectGrantsFrom(grantsBox);
changes.push({ user: username, grants });
});
const changes = [];
rows.forEach(row => {
if (row.getAttribute("data-admin") === "1") return; // skip admins
const username = String(row.getAttribute("data-username") || "").trim();
if (!username) return;
const grantsBox = row.querySelector(".folder-grants-box");
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
const grants = collectGrantsFrom(grantsBox);
changes.push({ user: username, grants });
});
try {
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
@@ -1284,70 +1422,70 @@ async function loadUserPermissionsList() {
const folders = await getAllFolders(true);
listContainer.innerHTML = "";
users.forEach(user => {
const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin";
users.forEach(user => {
const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin";
const row = document.createElement("div");
row.classList.add("user-permission-row");
row.setAttribute("data-username", user.username);
if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins
row.style.padding = "6px 0";
const row = document.createElement("div");
row.classList.add("user-permission-row");
row.setAttribute("data-username", user.username);
if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins
row.style.padding = "6px 0";
row.innerHTML = `
row.innerHTML = `
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
<strong>${user.username}</strong>
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
</div>
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
<div class="folder-grants-box" data-loaded="0"></div>
</div>
`;
const header = row.querySelector(".user-perm-header");
const details = row.querySelector(".user-perm-details");
const caret = row.querySelector(".perm-caret");
const grantsBox = row.querySelector(".folder-grants-box");
const header = row.querySelector(".user-perm-header");
const details = row.querySelector(".user-perm-details");
const caret = row.querySelector(".perm-caret");
const grantsBox = row.querySelector(".folder-grants-box");
async function ensureLoaded() {
if (grantsBox.dataset.loaded === "1") return;
try {
let grants;
if (isAdmin) {
// synthesize full access
const ordered = ["root", ...folders.filter(f => f !== "root")];
grants = buildFullGrantsForAllFolders(ordered);
renderFolderGrantsUI(user.username, grantsBox, ordered, grants);
// disable all inputs
grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true);
} else {
const userGrants = await getUserGrants(user.username);
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants);
async function ensureLoaded() {
if (grantsBox.dataset.loaded === "1") return;
try {
let grants;
if (isAdmin) {
// synthesize full access
const ordered = ["root", ...folders.filter(f => f !== "root")];
grants = buildFullGrantsForAllFolders(ordered);
renderFolderGrantsUI(user.username, grantsBox, ordered, grants);
// disable all inputs
grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true);
} else {
const userGrants = await getUserGrants(user.username);
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants);
}
grantsBox.dataset.loaded = "1";
} catch (e) {
console.error(e);
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
}
}
grantsBox.dataset.loaded = "1";
} catch (e) {
console.error(e);
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
}
}
function toggleOpen() {
const willShow = details.style.display === "none";
details.style.display = willShow ? "block" : "none";
header.setAttribute("aria-expanded", willShow ? "true" : "false");
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
if (willShow) ensureLoaded();
}
function toggleOpen() {
const willShow = details.style.display === "none";
details.style.display = willShow ? "block" : "none";
header.setAttribute("aria-expanded", willShow ? "true" : "false");
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
if (willShow) ensureLoaded();
}
header.addEventListener("click", toggleOpen);
header.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
});
header.addEventListener("click", toggleOpen);
header.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
});
listContainer.appendChild(row);
});
listContainer.appendChild(row);
});
} catch (err) {
console.error(err);
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";

View File

@@ -86,7 +86,7 @@ export function initializeApp() {
// Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
fileListArea.addEventListener('dragover', e => {
e.preventDefault();
@@ -136,13 +136,30 @@ export function initializeApp() {
LOGOUT (shared)
========================= */
export function triggerLogout() {
const clearWelcomeFlags = () => {
try {
// one-per-tab toast guard
sessionStorage.removeItem('__fr_welcomed');
// if you also used the per-user (all-tabs) guard, clear that too:
const u = localStorage.getItem('username') || '';
if (u) localStorage.removeItem(`__fr_welcomed_${u}`);
} catch { }
};
_nativeFetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": getCsrfToken() }
})
.then(() => window.location.reload(true))
.catch(() => { /* no-op */ });
.then(() => {
clearWelcomeFlags();
window.location.reload(true);
})
.catch(() => {
// even if the request fails, clear the flags so the next login can toast
clearWelcomeFlags();
window.location.reload(true);
});
}
/* =========================

View File

@@ -31,6 +31,49 @@ const currentOIDCConfig = {
};
window.currentOIDCConfig = currentOIDCConfig;
(function installToastFilter() {
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
// Suppress the nag while doing TOTP step-up
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
return null; // suppress
}
// Demo host
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
return "Demo site — use:\nUsername: demo\nPassword: demo";
}
// Try to translate keys; pass through plain text
try {
const maybe = t(msgKeyOrText);
if (typeof maybe === 'string' && maybe !== msgKeyOrText) return maybe;
} catch { }
return msgKeyOrText;
};
})();
function queueWelcomeToast(name) {
const uname = String(name || '').trim().slice(0, 80);
if (!uname) return;
// show immediately (if we dont reload instantly)
try {
window.dispatchEvent(new CustomEvent('filerise:toast', {
detail: { message: `Welcome back, ${uname}!`, duration: 2000 }
}));
} catch { }
// and persist for after-reload (flushed by main.js on boot)
try {
sessionStorage.setItem('welcomeMessage', `Welcome back, ${uname}!`);
} catch { }
}
/* ----------------- TOTP & Toast Overrides ----------------- */
// detect if were in a pendingTOTP state
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
@@ -72,45 +115,51 @@ const originalFetch = window.fetch;
* @param {object} options
* @returns {Promise<Response>}
*/
export async function fetchWithCsrf(url, options = {}) {
// 1) Merge in credentials + header
options = {
credentials: 'include',
...options,
};
const original = window.fetch.bind(window);
const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{');
options = { credentials: 'include', ...options };
options.headers = {
...(options.headers || {}),
'X-CSRF-Token': window.csrfToken,
'Accept': 'application/json',
...(options.headers || {})
};
// 2) First attempt
let res = await originalFetch(url, options);
// 3) If we got a 403, try to refresh token & retry
if (res.status === 403) {
// 3a) See if the server gave us a new token header
let newToken = res.headers.get('X-CSRF-Token');
// 3b) Otherwise fall back to the /api/auth/token endpoint
if (!newToken) {
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json();
newToken = body.csrf_token;
}
}
if (newToken) {
// 3c) Update global + meta
window.csrfToken = newToken;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = newToken;
// 3d) Retry the original request with the new token
options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options);
}
if (window.csrfToken) {
options.headers['X-CSRF-Token'] = window.csrfToken;
}
// 4) Return the real Response—no body peeking here!
async function retryWithFreshCsrf(asFormFallback = false) {
const tokRes = await original('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json().catch(() => ({}));
if (body?.csrf_token) {
window.csrfToken = body.csrf_token;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = body.csrf_token;
options.headers['X-CSRF-Token'] = body.csrf_token;
}
}
if (asFormFallback && wantJson) {
// convert JSON body into x-www-form-urlencoded
const orig = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
options.body = toFormBody(orig);
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
return original(url, options);
}
let res = await original(url, options);
// If API doesnt like JSON or token is stale
if (res.status === 400 || res.status === 403 || res.status === 415) {
// 1) retry with fresh CSRF keeping same encoding
res = await retryWithFreshCsrf(false);
if (!res.ok && wantJson) {
// 2) retry again as form-encoded
res = await retryWithFreshCsrf(true);
}
}
return res;
}
@@ -191,13 +240,13 @@ export function loadAdminConfigFunc() {
document.title = headerTitle;
const lo = config.loginOptions || {};
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
// These may be absent for non-admins; default them
localStorage.setItem("authBypass", String(!!lo.authBypass));
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
localStorage.setItem("authBypass", String(!!lo.authBypass));
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
updateLoginOptionsUIFromStorage();
@@ -253,14 +302,14 @@ export async function updateAuthenticatedUI(data) {
if (loading) loading.remove();
// 2) Show main UI
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
toggleVisibility("loginForm", false);
toggleVisibility("mainOperations", true);
toggleVisibility("uploadFileForm", true);
toggleVisibility("fileListContainer", true);
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
document.querySelector(".header-buttons").style.visibility = "visible";
// 3) Persist auth flags (unchanged)
@@ -271,9 +320,9 @@ export async function updateAuthenticatedUI(data) {
localStorage.setItem("username", data.username);
}
if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
}
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
@@ -282,7 +331,7 @@ export async function updateAuthenticatedUI(data) {
// 5) Build / update header buttons
const headerButtons = document.querySelector(".header-buttons");
const firstButton = headerButtons.firstElementChild;
const firstButton = headerButtons.firstElementChild;
// a) restore-from-trash for admins
if (data.isAdmin) {
@@ -290,8 +339,8 @@ export async function updateAuthenticatedUI(data) {
if (!r) {
r = document.createElement("button");
r.id = "restoreFilesBtn";
r.classList.add("btn","btn-warning");
r.setAttribute("data-i18n-title","trash_restore_delete");
r.classList.add("btn", "btn-warning");
r.setAttribute("data-i18n-title", "trash_restore_delete");
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
if (firstButton) insertAfter(r, firstButton);
else headerButtons.appendChild(r);
@@ -308,8 +357,8 @@ export async function updateAuthenticatedUI(data) {
if (!a) {
a = document.createElement("button");
a.id = "adminPanelBtn";
a.classList.add("btn","btn-info");
a.setAttribute("data-i18n-title","admin_panel");
a.classList.add("btn", "btn-info");
a.setAttribute("data-i18n-title", "admin_panel");
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
insertAfter(a, document.getElementById("restoreFilesBtn"));
a.addEventListener("click", openAdminPanel);
@@ -330,19 +379,19 @@ export async function updateAuthenticatedUI(data) {
: `<i class="material-icons">account_circle</i>`;
// fallback username if missing
const usernameText = data.username
|| localStorage.getItem("username")
const usernameText = data.username
|| localStorage.getItem("username")
|| "";
if (!dd) {
dd = document.createElement("div");
dd.id = "userDropdown";
dd.id = "userDropdown";
dd.classList.add("user-dropdown");
// toggle button
const toggle = document.createElement("button");
toggle.id = "userDropdownToggle";
toggle.classList.add("btn","btn-user");
toggle.id = "userDropdownToggle";
toggle.classList.add("btn", "btn-user");
toggle.setAttribute("title", t("user_settings"));
toggle.innerHTML = `
${avatarHTML}
@@ -464,6 +513,14 @@ function checkAuthentication(showLoginToast = true) {
}
updateAuthenticatedUI(data);
return data;
// at the end of updateAuthenticatedUI(data)
if (!window.__FR_FLAGS?.initialized && typeof initializeApp === 'function') {
initializeApp();
window.__FR_FLAGS.initialized = true;
}
if (typeof applyTranslations === 'function') applyTranslations();
if (typeof updateLoginOptionsUIFromStorage === 'function') updateLoginOptionsUIFromStorage();
} else {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
@@ -484,53 +541,162 @@ function checkAuthentication(showLoginToast = true) {
}
/* ----------------- Authentication Submission ----------------- */
async function primeCsrfStrict() {
const r = await fetch('/api/auth/token.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.csrf_token) throw new Error('CSRF missing');
window.csrfToken = j.csrf_token;
const m = document.querySelector('meta[name="csrf-token"]');
if (m) m.content = j.csrf_token;
}
function toFormBody(obj) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj || {})) p.set(k, v == null ? '' : String(v));
return p.toString();
}
async function safeJson(res) {
const ct = res.headers.get('content-type') || '';
if (!/application\/json/i.test(ct)) return null;
try { return await res.clone().json(); } catch { return null; }
}
async function sniffTOTP(res, bodyMaybe) {
if (res.headers.get('X-TOTP-Required') === '1') return true;
if (res.redirected && /[?&]totp_required=1\b/.test(res.url)) return true;
const body = bodyMaybe ?? await safeJson(res);
if (body && (body.totp_required || body.error === 'TOTP_REQUIRED')) return true;
try {
const txt = await res.clone().text();
if (/\btotp_required\s*=\s*1\b/i.test(txt)) return true;
} catch { }
return false;
}
async function isAuthedNow() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
return !!j.authenticated;
} catch { return false; }
}
function rafTick(times = 2) {
return new Promise(res => {
const step = () => { if (--times <= 0) res(); else requestAnimationFrame(step); };
requestAnimationFrame(step);
});
}
async function fetchAuthSnapshot() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
return await r.json();
} catch { return {}; }
}
async function syncPermissionsToLocalStorage() {
try {
const r = await fetch('/api/getUserPermissions.php', { credentials: 'include' });
const perm = await r.json();
if (perm && typeof perm === 'object') {
localStorage.setItem('folderOnly', perm.folderOnly ? 'true' : 'false');
localStorage.setItem('readOnly', perm.readOnly ? 'true' : 'false');
localStorage.setItem('disableUpload', perm.disableUpload ? 'true' : 'false');
}
} catch { /* non-fatal */ }
}
// ——— main ———
let __loginInFlight = false;
async function submitLogin(data) {
setLastLoginData(data);
window.__lastLoginData = data;
if (__loginInFlight) return;
__loginInFlight = true;
const payload = {
username: String(data.username || '').trim(),
password: String(data.password || '').trim(),
remember_me: data.remember_me ? 1 : 0
};
setLastLoginData(payload);
window.__lastLoginData = payload;
try {
// ─── 1) Get CSRF for the initial auth call ───
let res = await fetch("/api/auth/token.php", { credentials: "include" });
if (!res.ok) throw new Error("Could not fetch CSRF token");
window.csrfToken = (await res.json()).csrf_token;
await primeCsrfStrict();
// ─── 2) Send credentials ───
const response = await sendRequest(
"/api/auth/auth.php",
"POST",
data,
{ "X-CSRF-Token": window.csrfToken }
);
// Attempt #1 — JSON
let res = await fetchWithCsrf('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
});
let body = await safeJson(res);
// ─── 3a) Full login (no TOTP) ───
if (response.success || response.status === "ok") {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
// … fetch permissions & reload …
// TOTP requested?
if (await sniffTOTP(res, body)) {
try { await primeCsrfStrict(); } catch { }
window.pendingTOTP = true;
try {
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
if (perm && typeof perm === "object") {
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
}
const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch { }
return window.location.reload();
return;
}
// ─── 3b) TOTP required ───
if (response.totp_required) {
// **Refresh** CSRF before the TOTP verify call
res = await fetch("/api/auth/token.php", { credentials: "include" });
if (res.ok) {
window.csrfToken = (await res.json()).csrf_token;
}
// now open the modal—any totp_verify fetch from here on will use the new token
return openTOTPLoginModal();
// Full success (no TOTP)
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// ─── 3c) Too many attempts ───
if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
// Cookie set but non-JSON body — double check session
if (!body && await isAuthedNow()) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Attempt #2 — form fallback
res = await fetchWithCsrf('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: toFormBody(payload)
});
body = await safeJson(res);
if (await sniffTOTP(res, body)) {
try { await primeCsrfStrict(); } catch { }
window.pendingTOTP = true;
try {
const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch { }
return;
}
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
if (!body && await isAuthedNow()) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Rate limit still respected
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
showToast(body.error);
const btn = document.querySelector("#authForm button[type='submit']");
if (btn) {
btn.disabled = true;
@@ -542,12 +708,12 @@ async function submitLogin(data) {
return;
}
// ─── 3d) Other failures ───
showToast("Login failed: " + (response.error || "Unknown error"));
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
} catch (err) {
const msg = err.message || err.error || "Unknown error";
showToast(`Login failed: ${msg}`);
} catch (e) {
showToast('Login failed: ' + (e.message || 'Unknown error'));
} finally {
__loginInFlight = false;
}
}
@@ -763,4 +929,4 @@ document.addEventListener("DOMContentLoaded", function () {
}
});
export { initAuth, checkAuthentication };
export { initAuth, checkAuthentication, openTOTPLoginModal };

20
public/js/defer-css.js Normal file
View File

@@ -0,0 +1,20 @@
// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe)
document.addEventListener('DOMContentLoaded', () => {
// Promote any preloaded core CSS
document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => {
const href = link.getAttribute('href');
if ([...document.querySelectorAll('link[rel="stylesheet"]')]
.some(s => s.getAttribute('href') === href)) return;
const sheet = document.createElement('link');
sheet.rel = 'stylesheet';
sheet.href = href;
document.head.appendChild(sheet);
});
// Optionally load non-critical icon/extra font CSS after first paint:
const extra = document.createElement('link');
extra.rel = 'stylesheet';
extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
document.head.appendChild(extra);
});

View File

@@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () {
const confirmDelete = document.getElementById("confirmDeleteFiles");
if (confirmDelete) {
confirmDelete.setAttribute("data-default", "");
confirmDelete.addEventListener("click", function () {
fetch("/api/file/deleteFiles.php", {
method: "POST",
@@ -316,6 +317,7 @@ document.addEventListener("DOMContentLoaded", () => {
// 2) Confirm button kicks off the zip+download
if (confirmZipBtn) {
confirmZipBtn.setAttribute("data-default", "");
confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim();
@@ -478,6 +480,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
const confirmCopy = document.getElementById("confirmCopyFiles");
if (confirmCopy) {
confirmCopy.setAttribute("data-default", "");
confirmCopy.addEventListener("click", function () {
const targetFolder = document.getElementById("copyTargetFolder").value;
if (!targetFolder) {
@@ -529,6 +532,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
const confirmMove = document.getElementById("confirmMoveFiles");
if (confirmMove) {
confirmMove.setAttribute("data-default", "");
confirmMove.addEventListener("click", function () {
const targetFolder = document.getElementById("moveTargetFolder").value;
if (!targetFolder) {
@@ -598,6 +602,7 @@ document.addEventListener("DOMContentLoaded", () => {
const submitBtn = document.getElementById("submitRenameFile");
if (submitBtn) {
submitBtn.setAttribute("data-default", "");
submitBtn.addEventListener("click", function () {
const newName = document.getElementById("newFileName").value.trim();
if (!newName || newName === window.fileToRename) {

View File

@@ -7,9 +7,17 @@ import { t } from './i18n.js?v={{APP_QVER}}';
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// Lazy-load CodeMirror modes on demand
//const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
const CM_LOCAL = "/vendor/codemirror/5.65.5/";
// ==== CodeMirror lazy loader ===============================================
const CM_BASE = "/vendor/codemirror/5.65.5/";
// Stamp-friendly helpers (the stamper will replace {{APP_QVER}})
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
const CORE = {
js: coreUrl("codemirror.min.js"),
css: coreUrl("codemirror.min.css"),
themeCss: coreUrl("theme/material-darker.min.css"),
};
// Which mode file to load for a given name/mime
const MODE_URL = {
@@ -40,6 +48,13 @@ const MODE_URL = {
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
};
// Mode dependency graph
const MODE_DEPS = {
"htmlmixed": ["xml", "javascript", "css"],
"application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits
"markdown": ["xml"]
};
// Map any mime/alias to the key we use in MODE_URL
function normalizeModeName(modeOption) {
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
@@ -49,62 +64,78 @@ function normalizeModeName(modeOption) {
return name;
}
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
const _loadedScripts = new Set();
const _loadedCss = new Set();
let _corePromise = null;
function loadScriptOnce(url) {
return new Promise((resolve, reject) => {
const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9"
const withQS = url; //+ '?v=' + ver;
const key = `cm:${withQS}`;
let s = document.querySelector(`script[data-key="${key}"]`);
if (s) {
if (s.dataset.loaded === "1") return resolve();
s.addEventListener("load", resolve);
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
return;
}
s = document.createElement("script");
s.src = withQS;
if (_loadedScripts.has(url)) return resolve();
const s = document.createElement("script");
s.src = url;
s.async = true;
s.dataset.key = key;
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
s.onload = () => { _loadedScripts.add(url); resolve(); };
s.onerror = () => reject(new Error(`Load failed: ${url}`));
document.head.appendChild(s);
});
}
function loadCssOnce(href) {
return new Promise((resolve, reject) => {
if (_loadedCss.has(href)) return resolve();
const l = document.createElement("link");
l.rel = "stylesheet";
l.href = href;
l.onload = () => { _loadedCss.add(href); resolve(); };
l.onerror = () => reject(new Error(`Load failed: ${href}`));
document.head.appendChild(l);
});
}
async function ensureCore() {
if (_corePromise) return _corePromise;
_corePromise = (async () => {
// load CSS first to avoid FOUC
await loadCssOnce(CORE.css);
await loadCssOnce(CORE.themeCss);
if (!window.CodeMirror) {
await loadScriptOnce(CORE.js);
}
})();
return _corePromise;
}
async function loadSingleMode(name) {
const rel = MODE_URL[name];
if (!rel) return;
// prepend base if needed
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
await loadScriptOnce(url);
}
function isModeRegistered(name) {
return !!(
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name])
);
}
async function ensureModeLoaded(modeOption) {
if (!window.CodeMirror) return;
await ensureCore();
const name = normalizeModeName(modeOption);
if (!name) return;
const isRegistered = () =>
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]);
if (isRegistered()) return;
const url = MODE_URL[name];
if (!url) return; // unknown -> stay in text/plain
// Dependencies
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
ensureModeLoaded("css"),
ensureModeLoaded("javascript")
]);
if (isModeRegistered(name)) return;
const deps = MODE_DEPS[name] || [];
for (const d of deps) {
if (!isModeRegistered(d)) await loadSingleMode(d);
}
if (name === "application/x-httpd-php") {
await ensureModeLoaded("htmlmixed");
}
await loadScriptOnce(CM_LOCAL + url);
await loadSingleMode(name);
}
// Public helper for callers (we keep your existing function name in use):
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
// ==== /CodeMirror lazy loader ===============================================
function getModeForFile(fileName) {
const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
@@ -215,7 +246,7 @@ export function editFile(fileName, folder) {
</div>
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
<div class="editor-footer">
<button id="saveBtn" class="btn btn-primary" disabled>${t("save")}</button>
<button id="saveBtn" class="btn btn-primary" data-default disabled>${t("save")} </button>
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
</div>
`;
@@ -246,20 +277,20 @@ export function editFile(fileName, folder) {
const theme = isDarkMode ? "material-darker" : "default";
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
// Helper to check whether a mode is currently registered
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name);
const isModeRegistered = () =>
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]);
// Start mode loading (dont block closing)
const modePromise = ensureModeLoaded(desiredMode);
// Start core+mode loading (dont block closing)
const modePromise = (async () => {
await ensureCore(); // load CM core + CSS
if (!forcePlainText) {
await ensureModeLoaded(desiredMode); // then load the needed mode + deps
}
})();
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
Promise.race([modePromise, timeout]).then(() => {
if (canceled) return;
if (!window.CodeMirror) {
// Core not present: keep plain <textarea>; enable Save and bail gracefully
document.getElementById("saveBtn").disabled = false;
@@ -267,7 +298,9 @@ export function editFile(fileName, folder) {
return;
}
const initialMode = (forcePlainText || !isModeRegistered()) ? "text/plain" : desiredMode;
const normName = normalizeModeName(desiredMode) || "text/plain";
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
const cmOptions = {
lineNumbers: !forcePlainText,
mode: initialMode,
@@ -319,8 +352,11 @@ export function editFile(fileName, folder) {
// If we started in plain text due to timeout, flip to the real mode once it arrives
modePromise.then(() => {
if (!canceled && !forcePlainText && isModeRegistered()) {
editor.setOption("mode", desiredMode);
if (!canceled && !forcePlainText) {
const nn = normalizeModeName(desiredMode);
if (nn && isModeRegistered(nn)) {
editor.setOption("mode", desiredMode);
}
}
}).catch(() => {
// If the mode truly fails to load, we just stay in plain text

View File

@@ -205,29 +205,84 @@ function wireSelectAll(fileListContent) {
/**
* Fuse.js fuzzy search helper
*/
function searchFiles(searchTerm) {
if (!searchTerm) return fileData;
let keys = [
{ name: 'name', weight: 0.1 },
{ name: 'uploader', weight: 0.1 },
{ name: 'tags.name', weight: 0.1 }
];
if (window.advancedSearchEnabled) {
keys.push({ name: 'content', weight: 0.7 });
}
// --- Lazy Fuse loader (drop-in, CSP-safe, no inline) ---
const FUSE_SRC = '/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}';
let _fuseLoadingPromise = null;
function loadScriptOnce(src) {
// cache by src so we don't append multiple <script> tags
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
const p = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
loadScriptOnce._cache.set(src, p);
return p;
}
function lazyLoadFuse() {
if (window.Fuse) return Promise.resolve(window.Fuse);
if (!_fuseLoadingPromise) {
_fuseLoadingPromise = loadScriptOnce(FUSE_SRC).then(() => window.Fuse);
}
return _fuseLoadingPromise;
}
// (Optional) warm-up call you can trigger from main.js after first render:
// import { warmUpSearch } from './fileListView.js?v={{APP_QVER}}';
// warmUpSearch();
// This just starts fetching Fuse in the background.
export function warmUpSearch() {
lazyLoadFuse().catch(() => {/* ignore; well fall back */});
}
// Lazy + backward-compatible search
function searchFiles(searchTerm) {
if (!searchTerm) return fileData;
// kick off Fuse load in the background, but don't await
lazyLoadFuse().catch(() => { /* ignore */ });
// keys config (matches your original)
const fuseKeys = [
{ name: 'name', weight: 0.1 },
{ name: 'uploader', weight: 0.1 },
{ name: 'tags.name', weight: 0.1 }
];
if (window.advancedSearchEnabled) {
fuseKeys.push({ name: 'content', weight: 0.7 });
}
// If Fuse is present, use it right away (synchronous API)
if (window.Fuse) {
const options = {
keys: keys,
keys: fuseKeys,
threshold: 0.4,
minMatchCharLength: 2,
ignoreLocation: true
};
const fuse = new Fuse(fileData, options);
let results = fuse.search(searchTerm);
return results.map(result => result.item);
const fuse = new window.Fuse(fileData, options);
const results = fuse.search(searchTerm);
return results.map(r => r.item);
}
// Fallback (first keystrokes before Fuse finishes loading):
// simple case-insensitive substring match on the same fields
const q = String(searchTerm).toLowerCase();
const hay = (v) => (v == null ? '' : String(v)).toLowerCase();
return fileData.filter(item => {
if (hay(item.name).includes(q)) return true;
if (hay(item.uploader).includes(q)) return true;
if (Array.isArray(item.tags) && item.tags.some(t => hay(t?.name).includes(q))) return true;
if (window.advancedSearchEnabled && hay(item.content).includes(q)) return true;
return false;
});
}
/**
* View mode toggle

View File

@@ -247,7 +247,7 @@ const translations = {
"login_options": "Login Options",
"disable_login_form": "Disable Login Form",
"disable_basic_http_auth": "Disable Basic HTTP Auth",
"disable_oidc_login": "Disable OIDC Login",
"disable_oidc_login": "Disable OIDC Login (OIDC Config Required to enable)",
"save_settings": "Save Settings",
"at_least_one_login_method": "At least one login method must remain enabled.",
"settings_updated_successfully": "Settings updated successfully.",

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,38 @@ function traverseFileTreePromise(item, path = "") {
});
}
// --- Lazy loader for Resumable.js (no CSP inline, cached, safe) ---
const RESUMABLE_SRC = '/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}';
let _resumableLoadPromise = null;
function loadScriptOnce(src) {
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
const p = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
loadScriptOnce._cache.set(src, p);
return p;
}
function lazyLoadResumable() {
if (window.Resumable) return Promise.resolve(window.Resumable);
if (!_resumableLoadPromise) {
_resumableLoadPromise = loadScriptOnce(RESUMABLE_SRC).then(() => window.Resumable);
}
return _resumableLoadPromise;
}
// Optional: let main.js prefetch it in the background
export function warmUpResumable() {
lazyLoadResumable().catch(() => {/* ignore warm-up failure */});
}
// Recursively retrieve files from DataTransfer items.
function getFilesFromDataTransferItems(items) {
const promises = [];
@@ -401,36 +433,49 @@ function processFiles(filesInput) {
Resumable.js Integration for File Picker Uploads
(Only files chosen via file input use Resumable; folder uploads use original code.)
----------------------------------------------------- */
const useResumable = true; // Enable resumable for file picker uploads
let resumableInstance;
function initResumableUpload() {
resumableInstance = new Resumable({
target: "/api/upload/upload.php",
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
folder: window.currentFolder || "root",
upload_token: window.csrfToken
})
const useResumable = true;
let resumableInstance = null;
let _pendingPickedFiles = []; // files picked before library/instance ready
let _resumableReady = false;
// Make init async-safe; it resolves when Resumable is constructed
async function initResumableUpload() {
if (resumableInstance) return;
// Load the library if needed
const ResumableCtor = await lazyLoadResumable().catch(err => {
console.error('Failed to load Resumable.js:', err);
return null;
});
if (!ResumableCtor) return;
// Construct the instance once
if (!resumableInstance) {
resumableInstance = new ResumableCtor({
target: "/api/upload/upload.php",
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
folder: window.currentFolder || "root",
upload_token: window.csrfToken
})
});
}
// keep query fresh when folder changes (call this from your folder nav code)
function updateResumableQuery() {
if (!resumableInstance) return;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
// if you're not using a function for query, do:
resumableInstance.opts.query.folder = window.currentFolder || 'root';
resumableInstance.opts.query.upload_token = window.csrfToken;
}
const fileInput = document.getElementById("file");
if (fileInput) {
// Assign Resumable to file input for file picker uploads.
resumableInstance.assignBrowse(fileInput);
fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
@@ -587,13 +632,24 @@ function initResumableUpload() {
showToast("Some files failed to upload. Please check the list.");
}
});
_resumableReady = true;
if (_pendingPickedFiles.length) {
updateResumableQuery();
for (const f of _pendingPickedFiles) resumableInstance.addFile(f);
_pendingPickedFiles = [];
}
}
/* -----------------------------------------------------
XHR-based submitFiles for DragandDrop (Folder) Uploads
----------------------------------------------------- */
function submitFiles(allFiles) {
const folderToUse = window.currentFolder || "root";
const folderToUse = (() => {
const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; }
})();
const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file");
@@ -857,32 +913,48 @@ function initUpload() {
}
if (fileInput) {
fileInput.addEventListener("change", function () {
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// For file picker, if resumable is enabled, let it handle the files.
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) resumableInstance.addFile(f);
} else {
// If still not ready (load error), fall back to your XHR path
processFiles(files);
}
} else {
processFiles(fileInput.files);
processFiles(files);
}
});
}
if (uploadForm) {
uploadForm.addEventListener("submit", function (e) {
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
if (!files || files.length === 0) {
if (!files || !files.length) {
showToast("No files selected.");
return;
}
// If files come from file picker (no relative path), use Resumable.
if (useResumable && (!files[0].customRelativePath || files[0].customRelativePath === "")) {
// Ensure current folder is updated.
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.upload();
showToast("Resumable upload started...");
// Resumable path (only for picked files, not folder uploads)
const first = files[0];
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
if (useResumable && !isFolderish) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// ensure folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
// fallback
submitFiles(files);
}
} else {
submitFiles(files);
}