// adminPanel.js import { t } from './i18n.js?v={{APP_QVER}}'; import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}'; import { showToast, toggleVisibility, attachEnterKeyListener, escapeHTML } from './domUtils.js?v={{APP_QVER}}'; import { sendRequest } from './networkUtils.js?v={{APP_QVER}}'; import { withBase } from './basePath.js?v={{APP_QVER}}'; import { initAdminStorageSection } from './adminStorage.js?v={{APP_QVER}}'; import { initAdminSponsorSection } from './adminSponsor.js?v={{APP_QVER}}'; import { initOnlyOfficeUI, collectOnlyOfficeSettingsForSave } from './adminOnlyOffice.js?v={{APP_QVER}}'; import { openClientPortalsModal } from './adminPortals.js?v={{APP_QVER}}'; import { openUserPermissionsModal, openUserGroupsModal, populateAdminUserHubSelect, fetchAllUsers, isAdminUser, computeGroupGrantMaskForUser, applyGroupLocksForUser } from './adminFolderAccess.js?v={{APP_QVER}}'; export { openUserPermissionsModal, openUserGroupsModal } from './adminFolderAccess.js?v={{APP_QVER}}'; const version = window.APP_VERSION || "dev"; // Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only. // Update this when I cut a new Pro ZIP. const PRO_LATEST_BUNDLE_VERSION = 'v1.5.0'; const PRO_API_LEVELS = { diskUsage: 2, search: 3, audit: 4, sources: 5 }; const PRO_API_MIN_VERSION_LABELS = { diskUsage: '1.2.0', search: '1.3.0', audit: '1.4.0', sources: '1.5.0' }; const CORE_REQUIRED_PRO_API_LEVEL = Math.max(...Object.values(PRO_API_LEVELS)); const DEFAULT_HEADER_TITLE = 'FileRise'; const PRO_DEFAULT_HEADER_TITLE = 'FileRise Pro'; function resolveHeaderTitle(rawTitle, isPro) { const cleaned = String(rawTitle || '').trim(); if (!cleaned || cleaned === DEFAULT_HEADER_TITLE) { return isPro ? PRO_DEFAULT_HEADER_TITLE : DEFAULT_HEADER_TITLE; } return cleaned; } function compareSemver(a, b) { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0); const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i++) { const na = pa[i] || 0; const nb = pb[i] || 0; if (na > nb) return 1; if (na < nb) return -1; } return 0; } // Ensure OIDC config object always exists if (!window.currentOIDCConfig || typeof window.currentOIDCConfig !== 'object') { window.currentOIDCConfig = {}; } async function loadVirusDetectionLog() { const tableBody = document.getElementById('virusLogTableBody'); const emptyEl = document.getElementById('virusLogEmpty'); const wrapper = document.getElementById('virusLogWrapper'); if (!wrapper || !tableBody || !emptyEl) return; // If Pro is not active, we just leave the static "Pro" notice alone. if (!window.__FR_IS_PRO) { return; } emptyEl.textContent = 'Loading recent detections…'; tableBody.innerHTML = ''; try { const res = await fetch('/api/admin/virusLog.php?limit=50', { method: 'GET', credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '', 'Accept': 'application/json', }, }); const data = await safeJson(res); if (!data || data.ok !== true) { const msg = (data && (data.error || data.message)) || 'Failed to load detection log.'; emptyEl.textContent = msg; return; } const entries = Array.isArray(data.entries) ? data.entries : []; if (!entries.length) { emptyEl.textContent = 'No virus detections have been logged yet.'; return; } emptyEl.textContent = 'Tip: hover or click a row to see full ClamAV details.'; tableBody.innerHTML = ''; entries.forEach(row => { const tr = document.createElement('tr'); // Build a compact ClamAV info summary for tooltip / click const infoParts = []; if (row.engine) { infoParts.push(`Engine: ${row.engine}`); } if ( typeof row.exitCode === 'number' || (typeof row.exitCode === 'string' && row.exitCode !== '') ) { infoParts.push(`Exit: ${row.exitCode}`); } if (row.source) { infoParts.push(`Source: ${row.source}`); } if (row.message) { // keep it single-line-ish for tooltip/toast const msg = String(row.message).replace(/\s+/g, ' ').trim(); if (msg) infoParts.push(`Message: ${msg}`); } const infoText = infoParts.join(' • '); tr.innerHTML = ` ${escapeHTML(row.ts || '')} ${escapeHTML(row.user || '')} ${escapeHTML(row.ip || '')} ${escapeHTML(row.file || '')} ${escapeHTML(row.folder || '')} `; if (infoText) { // Native browser tooltip on hover tr.title = infoText; // Visual hint that row is interactive tr.style.cursor = 'pointer'; // Click to show toast with same info tr.addEventListener('click', () => { showToast(infoText); }); } tableBody.appendChild(tr); }); } catch (e) { console.error('Failed to load virus detection log', e); emptyEl.textContent = 'Failed to load detection log.'; } } async function downloadVirusLogCsv() { const emptyEl = document.getElementById('virusLogEmpty'); if (emptyEl) { emptyEl.textContent = 'Preparing CSV…'; } try { const res = await fetch('/api/admin/virusLog.php?limit=2000&format=csv', { method: 'GET', credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '', 'Accept': 'text/csv,text/plain;q=0.9,*/*;q=0.8', }, }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'filerise-virus-log.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); if (emptyEl && emptyEl.textContent === 'Preparing CSV…') { emptyEl.textContent = ''; } } catch (e) { console.error('Failed to download virus log CSV', e); if (emptyEl) { emptyEl.textContent = 'Failed to download CSV.'; } showToast(t('admin_csv_download_failed'), 'error'); } } function initVirusLogUI({ isPro }) { const uploadScope = document.getElementById('uploadContent'); if (!uploadScope) return; const wrapper = uploadScope.querySelector('#virusLogWrapper'); if (!wrapper) return; // global hint for loadVirusDetectionLog window.__FR_IS_PRO = !!isPro; if (!isPro) { // Free/core: we just show the static Pro alert text, nothing to wire return; } const refreshBtn = uploadScope.querySelector('#virusLogRefreshBtn'); const downloadBtn = uploadScope.querySelector('#virusLogDownloadCsvBtn'); if (refreshBtn && !refreshBtn.__wired) { refreshBtn.__wired = true; refreshBtn.addEventListener('click', (e) => { e.preventDefault(); loadVirusDetectionLog(); }); } if (downloadBtn && !downloadBtn.__wired) { downloadBtn.__wired = true; downloadBtn.addEventListener('click', (e) => { e.preventDefault(); downloadVirusLogCsv(); }); } // Initial load loadVirusDetectionLog(); } function normalizeLogoPath(raw) { if (!raw) return ''; const parts = String(raw).split(':'); let pic = parts[parts.length - 1]; pic = pic.replace(/^:+/, ''); if (pic && !pic.startsWith('/')) pic = '/' + pic; return pic; } function getAdminTitle(isPro, proVersion, updatesExpired = false) { const corePill = ` Core ${version} `; // Normalize versions so "v1.0.1" and "1.0.1" compare cleanly const norm = (v) => String(v || '').trim().replace(/^v/i, ''); const latestRaw = (typeof PRO_LATEST_BUNDLE_VERSION !== 'undefined' ? PRO_LATEST_BUNDLE_VERSION : '' ); const currentRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : ''; const hasCurrent = !!norm(currentRaw); const hasLatest = !!norm(latestRaw); const hasUpdate = isPro && hasCurrent && hasLatest && norm(currentRaw) !== norm(latestRaw); if (!isPro) { // Free/core only return ` ${t("admin_panel")} ${corePill} `; } const pvLabel = hasCurrent ? `Pro v${norm(currentRaw)}` : 'Pro'; const proPill = ` ${pvLabel} `; const updateHint = hasUpdate ? (updatesExpired ? ` Renew to unlock new Pro features ` : ` Pro update available `) : ''; return ` ${t("admin_panel")} ${corePill} ${proPill} ${updateHint} `; } 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 }; return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {}); } function applyHeaderColorsFromAdmin() { try { const lightInput = document.getElementById('brandingHeaderBgLight'); const darkInput = document.getElementById('brandingHeaderBgDark'); const root = document.documentElement; const light = lightInput ? lightInput.value.trim() : ''; const dark = darkInput ? darkInput.value.trim() : ''; if (light) root.style.setProperty('--header-bg-light', light); else root.style.removeProperty('--header-bg-light'); if (dark) root.style.setProperty('--header-bg-dark', dark); else root.style.removeProperty('--header-bg-dark'); } catch (e) { console.warn('Failed to live-update header colors from admin panel', e); } } function applyFooterFromAdmin() { try { const footerEl = document.getElementById('siteFooter'); if (!footerEl) return; const val = (document.getElementById('brandingFooterHtml')?.value || '').trim(); if (val) { // Show raw text in the live preview; HTML will be rendered on real page load footerEl.textContent = val; } else { const year = new Date().getFullYear(); footerEl.innerHTML = `© ${year} FileRise`; } } catch (e) { console.warn('Failed to live-update footer from admin panel', e); } } function updateHeaderLogoFromAdmin() { try { const input = document.getElementById('brandingCustomLogoUrl'); const logoImg = document.querySelector('.header-logo img'); if (!logoImg) return; const sanitizeLogoUrl = (raw) => { let url = (raw || '').trim(); if (!url) return ''; // If they used a bare "uploads/..." path, normalize to "/uploads/..." if (!url.startsWith('/') && url.startsWith('uploads/')) { url = '/' + url; } // Strip any CR/LF just in case url = url.replace(/[\r\n]+/g, ''); if (url.startsWith('/')) { if (url.includes('://')) return ''; return withBase(url); } try { const parsed = new URL(url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; return parsed.toString(); } catch (e) { return ''; } }; const safeUrl = sanitizeLogoUrl((input && input.value) || ''); if (safeUrl) { logoImg.setAttribute('src', safeUrl); logoImg.setAttribute('alt', 'Site logo'); } else { // fall back to default FileRise logo logoImg.setAttribute('src', withBase('/assets/logo.svg?v={{APP_QVER}}')); logoImg.setAttribute('alt', 'FileRise'); } } catch (e) { console.warn('Failed to live-update header logo from admin panel', e); } } /* === 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 enforceShareFolderRule(row) { const manage = qs(row, 'input[data-cap="manage"]'); const viewAll = qs(row, 'input[data-cap="view"]'); const shareFolder = qs(row, 'input[data-cap="shareFolder"]'); if (!shareFolder) return; const ok = !!(manage && manage.checked) && !!(viewAll && viewAll.checked); if (!ok) { shareFolder.checked = false; shareFolder.disabled = true; shareFolder.setAttribute('data-disabled-reason', 'Requires Manage + View (all)'); } else { shareFolder.disabled = false; shareFolder.removeAttribute('data-disabled-reason'); } } function wireHeaderTitleLive() { const input = document.getElementById('headerTitle'); if (!input || input.__live) return; input.__live = true; const apply = (val) => { const title = resolveHeaderTitle(val, window.__FR_IS_PRO === true); 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 (e) { } }; // 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)"' : 'data-replace="1"'; const replaceBtn = hasValue ? `` : ''; const note = hasValue ? `Saved — leave blank to keep` : ''; return `
${replaceBtn}
${note}
`; } 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 wireOidcTestButton(scope = document) { const btn = scope.querySelector('#oidcTestBtn'); const statusEl = scope.querySelector('#oidcTestStatus'); if (!btn || !statusEl || btn.__wired) return; btn.__wired = true; btn.addEventListener('click', async () => { const urlInput = scope.querySelector('#oidcProviderUrl'); const redirectInput = scope.querySelector('#oidcRedirectUri'); const providerUrl = (urlInput && urlInput.value.trim()) || ''; const redirectUri = (redirectInput && redirectInput.value.trim()) || ''; statusEl.textContent = providerUrl ? `Testing discovery for ${providerUrl}…` : 'Testing saved OIDC configuration…'; statusEl.className = 'small text-muted'; try { const res = await fetch('/api/admin/oidcTest.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken || '' }, body: JSON.stringify({ providerUrl: providerUrl || null, redirectUri: redirectUri || null }) }); const data = await safeJson(res); if (!data || data.success !== true) { const msg = (data && (data.error || data.message)) || 'OIDC test failed.'; statusEl.textContent = msg; statusEl.className = 'small text-danger'; showToast(t('admin_oidc_test_failed_detail', { error: msg }), 'error'); return; } const parts = []; const authEndpoint = data.authorization_endpoint || data.authorizationUrl; const userinfoEndpoint = data.userinfo_endpoint || data.userinfoUrl; if (data.issuer) parts.push('issuer: ' + data.issuer); if (authEndpoint) parts.push('auth: ' + authEndpoint); if (userinfoEndpoint) parts.push('userinfo: ' + userinfoEndpoint); const summary = parts.length ? 'OK – ' + parts.join(' • ') : 'OK – provider discovery succeeded.'; statusEl.textContent = summary; statusEl.className = 'small text-success'; showToast(t('admin_oidc_discovery_reachable')); if (Array.isArray(data.warnings) && data.warnings.length) { console.warn('OIDC test warnings:', data.warnings); } } catch (e) { console.error('OIDC test error', e); statusEl.textContent = 'Error: ' + (e && e.message ? e.message : String(e)); statusEl.className = 'small text-danger'; showToast(t('admin_oidc_test_failed_console'), 'error'); } }); } function wireClamavTestButton(scope = document) { const btn = scope.querySelector('#clamavTestBtn'); const statusEl = scope.querySelector('#clamavTestStatus'); if (!btn || !statusEl || btn.__wired) return; btn.__wired = true; btn.addEventListener('click', async () => { statusEl.textContent = 'Running ClamAV self-test…'; statusEl.className = 'small text-muted'; try { const res = await fetch('/api/admin/clamavTest.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken || '' }, body: JSON.stringify({}) }); const data = await safeJson(res).catch(err => { // safeJson throws on !res.ok, so catch to show a nicer message console.error('ClamAV test HTTP error', err); return null; }); if (!data || data.success !== true) { const msg = (data && (data.error || data.message)) || 'ClamAV test failed.'; statusEl.textContent = msg; statusEl.className = 'small text-danger'; showToast(msg, 'error'); return; } const cmd = data.command || 'clamscan'; const engine = data.engine || ''; const details = data.details || ''; const parts = []; parts.push(`OK – ${cmd} is reachable`); if (engine) parts.push(engine); if (details) parts.push(details); statusEl.textContent = parts.join(' • '); statusEl.className = 'small text-success'; showToast(t('admin_clamav_selftest_ok')); } catch (e) { console.error('ClamAV test error', e); statusEl.textContent = 'ClamAV test error: ' + (e && e.message ? e.message : String(e)); statusEl.className = 'small text-danger'; showToast(t('admin_clamav_test_failed_console'), 'error'); } }); } function wireIgnoreRegexPresetButton(scope = document) { const btn = scope.querySelector('#ignoreRegexSnapshotsPreset'); const input = scope.querySelector('#ignoreRegex'); if (!btn || !input || btn.__wired) return; btn.__wired = true; const preset = '(^|/)(@?snapshots?)(/|$)'; btn.addEventListener('click', (e) => { e.preventDefault(); if (input.disabled) return; const current = String(input.value || ''); const hasPreset = current .replace(/\r\n/g, '\n') .split('\n') .some(line => line.trim() === preset); if (hasPreset) { input.focus(); return; } if (current.trim() === '') { input.value = preset; } else { const suffix = current.endsWith('\n') ? '' : '\n'; input.value = current + suffix + preset; } input.focus(); }); } function renderAdminEncryptionSection({ config, dark }) { const host = document.getElementById("encryptionContent"); if (!host) return; const enc = (config && config.encryption && typeof config.encryption === 'object') ? config.encryption : {}; const supported = !!enc.supported; const hasMasterKey = !!enc.hasMasterKey; const source = String(enc.source || 'missing'); const lockedByEnv = !!enc.lockedByEnv; const envPresent = !!enc.envPresent; const filePresent = !!enc.filePresent; const canGenerateKey = !lockedByEnv && !filePresent; const statusPill = (ok, label) => ` ${label} `; const sourceLabel = (() => { if (source === 'env') return 'Env (FR_ENCRYPTION_MASTER_KEY)'; if (source === 'env_invalid') return 'Env present but invalid'; if (source === 'file') return 'Key file (META_DIR/encryption_master.key)'; if (source === 'file_invalid') return 'Key file present but invalid'; return 'Missing'; })(); host.innerHTML = `
${tf("encryption_at_rest", "Encryption at rest")} ${statusPill(supported, supported ? tf("supported", "Supported") : tf("not_supported", "Not supported"))} ${statusPill(hasMasterKey, hasMasterKey ? tf("configured", "Configured") : tf("missing", "Missing"))}
${tf("encryption_help_short", "Folder encryption requires a server master key. Env overrides the key file.")}
${tf("master_key_source", "Master key source")}: ${escapeHTML(sourceLabel)}
${tf("env_present", "Env present")}: ${envPresent ? 'Yes' : 'No'}${lockedByEnv ? ' (locked)' : ''}
${tf("key_file_present", "Key file present")}: ${filePresent ? 'Yes' : 'No'}

${lockedByEnv ? `
${tf("locked_by_env", "Locked by FR_ENCRYPTION_MASTER_KEY env override.")}
` : ''}
${tf("encryption_v1_note", "Admin notes:")}
`; const post = async (action, key, extra = {}) => { const res = await fetch(withBase('/api/admin/setEncryptionKey.php'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken || '' }, body: JSON.stringify({ action, ...(key ? { key } : {}), ...extra }) }); const text = await res.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch (e) { /* ignore */ } return { ok: res.ok, status: res.status, body: body || {}, raw: text || '' }; }; const refresh = async () => { const r = await fetch(withBase('/api/admin/getConfig.php?ts=' + Date.now()), { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-store' } }); const next = await safeJson(r); renderAdminEncryptionSection({ config: next, dark }); }; const genBtn = document.getElementById('frEncGenerateKeyBtn'); if (genBtn && !genBtn.__wired) { genBtn.__wired = true; genBtn.addEventListener('click', async () => { try { genBtn.disabled = true; const res = await post('generate'); if (!res.ok) { throw new Error(res.body?.message || res.body?.error || `HTTP ${res.status}`); } showToast(tf("key_file_created", "Key file created.")); await refresh(); } catch (e) { console.error(e); showToast((e && e.message) ? e.message : tf("error", "Error"), 'error'); } finally { genBtn.disabled = !canGenerateKey; } }); } const clearBtn = document.getElementById('frEncClearKeyBtn'); if (clearBtn && !clearBtn.__wired) { clearBtn.__wired = true; clearBtn.addEventListener('click', async () => { try { clearBtn.disabled = true; const ok = await showCustomConfirmModal( "Removing the encryption key file can make encrypted files permanently unreadable. Continue?" ); if (!ok) { clearBtn.disabled = lockedByEnv; return; } let res = await post('clear'); if (!res.ok && res.status === 409) { const errCode = res.body?.error || ''; if (errCode === 'locked_by_env') { showToast(res.body?.message || t('admin_key_file_locked_env'), 'error'); clearBtn.disabled = lockedByEnv; return; } if (errCode === 'not_supported') { showToast(res.body?.message || t('admin_encryption_not_supported'), 'error'); clearBtn.disabled = lockedByEnv; return; } const summary = res.body?.summary || {}; const encCount = Number(summary.encryptedCount || 0); const jobCount = Number(summary.activeJobs || 0); const scan = summary.scan || null; const details = []; if (encCount > 0) details.push(`${encCount} encrypted folder(s).`); if (jobCount > 0) details.push(`${jobCount} active crypto job(s).`); if (scan && scan.scanned) { const scanned = Number(scan.scanned || 0); if (errCode === 'encrypted_files_detected') { details.push(`Scan found an encrypted file after checking ${scanned} file(s).`); } else { details.push(`Scan checked ${scanned} file(s) for encrypted headers${scan.truncated ? ' (truncated)' : ''}.`); } } const extra = details.length ? details.join('\n') : ''; const reasonLine = (() => { if (errCode === 'encrypted_files_detected') return 'Encrypted files detected on disk.'; if (errCode === 'encrypted_files_scan_truncated') return 'Encrypted file scan was truncated.'; if (errCode === 'encrypted_files_scan_failed') return 'Encrypted file scan failed.'; if (errCode === 'encrypted_folders_exist') return 'Encrypted folders still exist.'; if (errCode === 'crypto_job_active') return 'An encryption job is still running.'; return ''; })(); const forceOk = await showTypedConfirmModal({ title: "Force remove key file", message: "This will permanently break access to encrypted files.\n\n" + (reasonLine ? reasonLine + "\n\n" : "") + (extra ? extra + "\n\n" : "") + (errCode === '' ? "\n\nServer returned 409 without details; assume encrypted data exists." : '') + "\n\nType REMOVE to confirm.", confirmText: "REMOVE", placeholder: "Type REMOVE to continue" }); if (!forceOk) { clearBtn.disabled = lockedByEnv; return; } res = await post('clear', null, { force: true }); } if (!res.ok) { throw new Error(res.body?.message || res.body?.error || `HTTP ${res.status}`); } showToast(tf("key_file_cleared", "Key file cleared.")); await refresh(); } catch (e) { console.error(e); showToast((e && e.message) ? e.message : tf("error", "Error"), 'error'); } finally { clearBtn.disabled = lockedByEnv; } }); } } function initVirusLogSection({ isPro }) { const uploadScope = document.getElementById('uploadContent'); if (!uploadScope) return; const wrapper = uploadScope.querySelector('#virusLogWrapper'); const shell = uploadScope.querySelector('#virusLogTableShell'); if (!wrapper || !shell) return; // Let us overlay a Pro banner on top of the table if (!wrapper.style.position) { wrapper.style.position = 'relative'; } // Remove any previous overlays wrapper.querySelectorAll('.virus-pro-overlay').forEach(el => el.remove()); // --- Free/core: show blurred preview + Pro banner --- if (!isPro) { shell.innerHTML = `
Timestamp (UTC) User IP File Folder
Virus detections from the last 30 days would appear here.
`; const overlay = document.createElement('div'); overlay.className = 'virus-pro-overlay'; overlay.style.cssText = ` position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; `; overlay.innerHTML = `
Pro Virus detection log is available in FileRise Pro. Learn more
`; wrapper.appendChild(overlay); return; } // --- Pro: load real data from /api/admin/virusLog.php --- shell.innerHTML = `
Loading virus detection log…
`; (async () => { try { const res = await fetch('/api/admin/virusLog.php?limit=200', { method: 'GET', credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '' } }); const data = await safeJson(res).catch(err => { console.error('virusLog HTTP error', err); return null; }); if (!data || data.ok === false) { const msg = (data && (data.error || data.message)) || 'Failed to load detection log.'; shell.innerHTML = `
${msg}
`; return; } const rows = Array.isArray(data.rows || data.entries || data.data) ? (data.rows || data.entries || data.data) : []; if (!rows.length) { shell.innerHTML = `
No virus detections have been logged yet.
`; return; } const escapeCell = (v) => { if (v === null || v === undefined) return ''; return String(v) .replace(/&/g, '&') .replace(//g, '>'); }; const normEntry = (e) => { const tsRaw = e.ts ?? e.timestamp ?? e.time ?? e.when ?? ''; let tsLabel = ''; if (typeof tsRaw === 'number') { const d = new Date(tsRaw * 1000); tsLabel = isNaN(d.getTime()) ? String(tsRaw) : d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); } else if (tsRaw) { const d = new Date(tsRaw); tsLabel = isNaN(d.getTime()) ? String(tsRaw) : d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); } return { ts: tsLabel || '', user: e.user ?? e.username ?? '', ip: e.ip ?? e.remote_ip ?? e.remoteIp ?? '', file: e.file ?? e.filename ?? e.name ?? '', folder: e.folder ?? e.path ?? e.dir ?? '' }; }; const normalized = rows.map(normEntry); let html = `
`; normalized.forEach(entry => { html += ` `; }); html += `
Timestamp (UTC) User IP File Folder
${escapeCell(entry.ts)} ${escapeCell(entry.user)} ${escapeCell(entry.ip)} ${escapeCell(entry.file)} ${escapeCell(entry.folder)}
`; shell.innerHTML = html; } catch (e) { console.error('virusLog error', e); shell.innerHTML = `
Error loading detection log. See console for details.
`; } })(); } function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSourcesApiOk }) { const container = document.getElementById('sourcesContent'); if (!container || container.__initialized) return; container.__initialized = true; if (!isPro) { const isDark = document.body.classList.contains('dark-mode'); const overlayBg = isDark ? 'rgba(15, 23, 42, 0.9)' : 'rgba(255, 255, 255, 0.92)'; const overlayText = isDark ? '#f8fafc' : '#111827'; const overlaySubtext = isDark ? 'rgba(226, 232, 240, 0.9)' : '#4b5563'; const overlayBorder = isDark ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)'; const overlayShadow = isDark ? '0 10px 28px rgba(0,0,0,0.5)' : '0 10px 24px rgba(0,0,0,0.18)'; const title = tf('sources_pro_locked_title', 'Sources are a Pro feature'); const body = tf( 'sources_pro_locked_body', 'Connect remote storage and manage it like local — switch sources, move/copy between them, and keep separate trash per source. Upgrade to FileRise Pro to add S3, SFTP, FTP, WebDAV, SMB, Local and Google Drive sources.' ); const help = tf('sources_help', 'Sources are separate roots; users only see sources they can access.'); const adapterHint = tf( 'sources_adapter_hint', 'Adapters: local, S3, SFTP, FTP, WebDAV, SMB, Google Drive.' ); container.innerHTML = `
${escapeHTML(tf('sources', 'Sources'))} Pro
${escapeHTML(help)}
${escapeHTML(help)}
${escapeHTML(tf('source_name', 'Source Name'))} ${escapeHTML(tf('source_type', 'Type'))} ${escapeHTML(tf('status', 'Status'))}
Locallocal${escapeHTML(tf('enabled', 'Enabled'))}
Archives3${escapeHTML(tf('disabled', 'Disabled'))}
Mediasmb${escapeHTML(tf('enabled', 'Enabled'))}
${escapeHTML(adapterHint)}
Pro ${escapeHTML(title)}
${escapeHTML(body)}
`; return; } if (!proSourcesApiOk) { const msg = tf( 'sources_pro_bundle_outdated', `Please upgrade to FileRise Pro v${PRO_API_MIN_VERSION_LABELS.sources}+ to use Sources.` ); container.innerHTML = `
${escapeHTML(msg)}
`; return; } container.innerHTML = `
${tf('sources_help', 'Sources are separate roots; users only see sources they can access.')}

${tf('source_add', 'Add Source')}
${tf('source_secret_note', 'Secrets are never shown after saving. Leave blank to keep existing values.')}
`; const statusEl = container.querySelector('#sourcesStatus'); const listEl = container.querySelector('#sourcesList'); const enabledToggle = container.querySelector('#sourcesEnabledToggle'); const addBtn = container.querySelector('#sourceAddBtn'); const refreshBtn = container.querySelector('#sourceRefreshBtn'); const formTitleEl = container.querySelector('#sourceFormTitle'); const saveBtn = container.querySelector('#sourceSaveBtn'); const resetBtn = container.querySelector('#sourceResetBtn'); const sourceIdEl = container.querySelector('#sourceId'); const sourceNameEl = container.querySelector('#sourceName'); const sourceTypeEl = container.querySelector('#sourceType'); const sourceEnabledEl = container.querySelector('#sourceEnabled'); const sourceReadOnlyEl = container.querySelector('#sourceReadOnly'); const localPathEl = container.querySelector('#sourceLocalPath'); const s3BucketEl = container.querySelector('#sourceS3Bucket'); const s3RegionEl = container.querySelector('#sourceS3Region'); const s3EndpointEl = container.querySelector('#sourceS3Endpoint'); const s3PrefixEl = container.querySelector('#sourceS3Prefix'); const s3AccessKeyEl = container.querySelector('#sourceS3AccessKey'); const s3SecretKeyEl = container.querySelector('#sourceS3SecretKey'); const s3SessionTokenEl = container.querySelector('#sourceS3SessionToken'); const s3PathStyleEl = container.querySelector('#sourceS3PathStyle'); const sftpHostEl = container.querySelector('#sourceSftpHost'); const sftpPortEl = container.querySelector('#sourceSftpPort'); const sftpUsernameEl = container.querySelector('#sourceSftpUsername'); const sftpPasswordEl = container.querySelector('#sourceSftpPassword'); const sftpPrivateKeyEl = container.querySelector('#sourceSftpPrivateKey'); const sftpPrivateKeyPassEl = container.querySelector('#sourceSftpPrivateKeyPassphrase'); const sftpRootEl = container.querySelector('#sourceSftpRoot'); const ftpHostEl = container.querySelector('#sourceFtpHost'); const ftpPortEl = container.querySelector('#sourceFtpPort'); const ftpUsernameEl = container.querySelector('#sourceFtpUsername'); const ftpPasswordEl = container.querySelector('#sourceFtpPassword'); const ftpSslEl = container.querySelector('#sourceFtpSsl'); const ftpPassiveEl = container.querySelector('#sourceFtpPassive'); const ftpRootEl = container.querySelector('#sourceFtpRoot'); const webdavUrlEl = container.querySelector('#sourceWebdavUrl'); const webdavUsernameEl = container.querySelector('#sourceWebdavUsername'); const webdavPasswordEl = container.querySelector('#sourceWebdavPassword'); const webdavRootEl = container.querySelector('#sourceWebdavRoot'); const webdavVerifyTlsEl = container.querySelector('#sourceWebdavVerifyTls'); const smbHostEl = container.querySelector('#sourceSmbHost'); const smbShareEl = container.querySelector('#sourceSmbShare'); const smbUsernameEl = container.querySelector('#sourceSmbUsername'); const smbPasswordEl = container.querySelector('#sourceSmbPassword'); const smbDomainEl = container.querySelector('#sourceSmbDomain'); const smbVersionEl = container.querySelector('#sourceSmbVersion'); const smbRootEl = container.querySelector('#sourceSmbRoot'); const gdriveClientIdEl = container.querySelector('#sourceGDriveClientId'); const gdriveClientSecretEl = container.querySelector('#sourceGDriveClientSecret'); const gdriveRefreshTokenEl = container.querySelector('#sourceGDriveRefreshToken'); const gdriveRootIdEl = container.querySelector('#sourceGDriveRootId'); const gdriveDriveIdEl = container.querySelector('#sourceGDriveDriveId'); const typeBlocks = Array.from(container.querySelectorAll('.sources-type-block')); let editingId = ''; let state = { enabled: !!sourcesEnabled, sources: Array.isArray(sourcesCfg.sources) ? sourcesCfg.sources : [], activeId: sourcesCfg.activeId || sourcesCfg.active || sourcesCfg.selected || sourcesCfg.current || '', testStatus: {} }; const esc = (val) => escapeHTML(val == null ? '' : String(val)); const getCsrf = () => document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken || ''; const setStatus = (msg, tone = 'muted') => { if (!statusEl) return; statusEl.className = `text-${tone} small`; statusEl.textContent = msg || ''; }; const setSavingState = (saving, message) => { if (saveBtn) { saveBtn.disabled = !!saving; } if (saving && message) { setStatus(message); } }; const refreshSourceSelectorSafe = async (origin) => { try { if (typeof window.__frRefreshSourceSelector === 'function') { await window.__frRefreshSourceSelector({ origin }); } } catch (e) { /* ignore */ } }; const normalizeTestState = (value) => (value === 'testing' || value === 'ok' || value === 'error') ? value : 'idle'; const truncateTestMessage = (msg, max = 80) => { const clean = String(msg || '').replace(/\s+/g, ' ').trim(); if (clean.length <= max) return clean; return clean.slice(0, max) + '...'; }; const testStatusLabel = (state, message) => { if (state === 'testing') return tf('source_test_running', 'Testing...'); if (state === 'ok') return tf('source_test_ok', 'Connected'); if (state === 'error') { const base = tf('source_test_failed', 'Failed'); const detail = message ? truncateTestMessage(message, 60) : ''; return detail ? `${base}: ${detail}` : base; } return tf('source_test_idle', 'Not tested'); }; const getTestStatus = (id) => { const entry = (state.testStatus && state.testStatus[id]) ? state.testStatus[id] : {}; return { state: normalizeTestState(entry.state), message: entry.message ? String(entry.message) : '' }; }; const setTestStatus = (id, next) => { if (!id) return; const current = (state.testStatus && state.testStatus[id]) ? state.testStatus[id] : {}; state.testStatus[id] = { ...current, ...next }; renderList(); }; const pruneTestStatus = () => { if (!state.testStatus) return; const ids = new Set((state.sources || []).map(src => String(src.id || ''))); Object.keys(state.testStatus).forEach(id => { if (!ids.has(id)) { delete state.testStatus[id]; } }); }; const setType = (type) => { const t = (type || 'local').toLowerCase(); sourceTypeEl.value = t; typeBlocks.forEach(block => { block.hidden = block.getAttribute('data-type') !== t; }); }; const resetSecrets = () => { [ s3AccessKeyEl, s3SecretKeyEl, s3SessionTokenEl, sftpPasswordEl, sftpPrivateKeyEl, sftpPrivateKeyPassEl, ftpPasswordEl, webdavPasswordEl, smbPasswordEl, gdriveClientSecretEl, gdriveRefreshTokenEl, ].forEach(el => { if (!el) return; el.value = ''; el.placeholder = ''; }); }; const resetForm = () => { editingId = ''; formTitleEl.textContent = tf('source_add', 'Add Source'); if (sourceIdEl) { sourceIdEl.disabled = false; sourceIdEl.value = ''; } if (sourceNameEl) sourceNameEl.value = ''; if (sourceEnabledEl) sourceEnabledEl.checked = true; if (sourceReadOnlyEl) sourceReadOnlyEl.checked = false; if (localPathEl) localPathEl.value = ''; if (s3BucketEl) s3BucketEl.value = ''; if (s3RegionEl) s3RegionEl.value = ''; if (s3EndpointEl) s3EndpointEl.value = ''; if (s3PrefixEl) s3PrefixEl.value = ''; if (s3PathStyleEl) s3PathStyleEl.checked = false; if (sftpHostEl) sftpHostEl.value = ''; if (sftpPortEl) sftpPortEl.value = ''; if (sftpUsernameEl) sftpUsernameEl.value = ''; if (sftpRootEl) sftpRootEl.value = ''; if (ftpHostEl) ftpHostEl.value = ''; if (ftpPortEl) ftpPortEl.value = ''; if (ftpUsernameEl) ftpUsernameEl.value = ''; if (ftpSslEl) ftpSslEl.checked = false; if (ftpPassiveEl) ftpPassiveEl.checked = true; if (ftpRootEl) ftpRootEl.value = ''; if (webdavUrlEl) webdavUrlEl.value = ''; if (webdavUsernameEl) webdavUsernameEl.value = ''; if (webdavRootEl) webdavRootEl.value = ''; if (webdavVerifyTlsEl) webdavVerifyTlsEl.checked = true; if (smbHostEl) smbHostEl.value = ''; if (smbShareEl) smbShareEl.value = ''; if (smbUsernameEl) smbUsernameEl.value = ''; if (smbDomainEl) smbDomainEl.value = ''; if (smbVersionEl) smbVersionEl.value = ''; if (smbRootEl) smbRootEl.value = ''; if (gdriveClientIdEl) gdriveClientIdEl.value = ''; if (gdriveRootIdEl) gdriveRootIdEl.value = ''; if (gdriveDriveIdEl) gdriveDriveIdEl.value = ''; resetSecrets(); setType('local'); if (listEl) renderList(); }; const applySecretPlaceholders = (src) => { const cfg = (src && src.config) ? src.config : {}; const setSaved = (el, has) => { if (!el) return; el.value = ''; el.placeholder = has ? '•••••• (saved)' : ''; }; setSaved(s3AccessKeyEl, !!cfg.hasAccessKey); setSaved(s3SecretKeyEl, !!cfg.hasSecretKey); setSaved(s3SessionTokenEl, !!cfg.hasSessionToken); setSaved(sftpPasswordEl, !!cfg.hasPassword); setSaved(sftpPrivateKeyEl, !!cfg.hasPrivateKey); setSaved(sftpPrivateKeyPassEl, !!cfg.hasPrivateKeyPassphrase); setSaved(ftpPasswordEl, !!cfg.hasPassword); setSaved(webdavPasswordEl, !!cfg.hasPassword); setSaved(smbPasswordEl, !!cfg.hasPassword); setSaved(gdriveClientSecretEl, !!cfg.hasClientSecret); setSaved(gdriveRefreshTokenEl, !!cfg.hasRefreshToken); }; const fillForm = (src) => { if (!src || typeof src !== 'object') return; editingId = String(src.id || ''); formTitleEl.textContent = tf('source_edit', 'Edit Source'); if (sourceIdEl) { sourceIdEl.value = src.id || ''; sourceIdEl.disabled = true; } if (sourceNameEl) sourceNameEl.value = src.name || ''; if (sourceEnabledEl) sourceEnabledEl.checked = src.enabled !== false; if (sourceReadOnlyEl) sourceReadOnlyEl.checked = !!src.readOnly; const type = (src.type || 'local').toLowerCase(); setType(type); const cfg = src.config || {}; if (localPathEl) localPathEl.value = cfg.path || cfg.root || ''; if (s3BucketEl) s3BucketEl.value = cfg.bucket || ''; if (s3RegionEl) s3RegionEl.value = cfg.region || ''; if (s3EndpointEl) s3EndpointEl.value = cfg.endpoint || ''; if (s3PrefixEl) s3PrefixEl.value = cfg.prefix || ''; if (s3PathStyleEl) s3PathStyleEl.checked = !!cfg.pathStyle; if (sftpHostEl) sftpHostEl.value = cfg.host || ''; if (sftpPortEl) sftpPortEl.value = cfg.port || ''; if (sftpUsernameEl) sftpUsernameEl.value = cfg.username || ''; if (sftpRootEl) sftpRootEl.value = cfg.root || cfg.path || ''; if (ftpHostEl) ftpHostEl.value = cfg.host || ''; if (ftpPortEl) ftpPortEl.value = cfg.port || ''; if (ftpUsernameEl) ftpUsernameEl.value = cfg.username || ''; if (ftpSslEl) ftpSslEl.checked = !!cfg.ssl; if (ftpPassiveEl) { const passive = (typeof cfg.passive === 'undefined') ? true : !!cfg.passive; ftpPassiveEl.checked = passive; } if (ftpRootEl) ftpRootEl.value = cfg.root || cfg.path || ''; if (webdavUrlEl) webdavUrlEl.value = cfg.baseUrl || cfg.url || ''; if (webdavUsernameEl) webdavUsernameEl.value = cfg.username || ''; if (webdavRootEl) webdavRootEl.value = cfg.root || cfg.path || ''; if (webdavVerifyTlsEl) { webdavVerifyTlsEl.checked = (typeof cfg.verifyTls === 'undefined') ? true : !!cfg.verifyTls; } if (smbHostEl) smbHostEl.value = cfg.host || ''; if (smbShareEl) smbShareEl.value = cfg.share || ''; if (smbUsernameEl) smbUsernameEl.value = cfg.username || ''; if (smbDomainEl) smbDomainEl.value = cfg.domain || ''; if (smbVersionEl) smbVersionEl.value = cfg.version || ''; if (smbRootEl) smbRootEl.value = cfg.root || cfg.path || ''; if (gdriveClientIdEl) gdriveClientIdEl.value = cfg.clientId || ''; if (gdriveRootIdEl) gdriveRootIdEl.value = cfg.rootId || ''; if (gdriveDriveIdEl) gdriveDriveIdEl.value = cfg.driveId || ''; applySecretPlaceholders(src); if (listEl) renderList(); }; const renderList = () => { const rows = Array.isArray(state.sources) ? state.sources : []; if (!rows.length) { listEl.innerHTML = `
${tf('source_list_empty', 'No sources configured yet.')}
`; return; } const selectedId = editingId ? String(editingId) : ''; const activeRowId = (selectedId && rows.some(src => String(src.id || '') === selectedId)) ? selectedId : ''; const header = `
${tf('source_name', 'Source Name')}
${tf('source_type', 'Type')}
${tf('source_id', 'Source ID')}
${tf('actions', 'Actions')}
`; const body = rows.map((src, idx) => { const id = String(src.id || ''); const name = String(src.name || id || ''); const type = String(src.type || ''); const enabledText = (src.enabled === false) ? tf('disabled', 'Disabled') : tf('enabled', 'Enabled'); const flags = [ enabledText, (src.readOnly ? t('read_only') : '') ].filter(Boolean); const badges = flags.map(flag => `${esc(flag)}`).join(''); const badgeWrap = badges ? `${badges}` : ''; const testState = getTestStatus(id); const statusText = testStatusLabel(testState.state, testState.message); const statusClass = `sources-test-status status-${testState.state}`; const statusTitle = testState.message ? ` title="${esc(testState.message)}"` : ''; const testDisabled = (testState.state === 'testing') ? 'disabled' : ''; const rowClass = [ 'sources-row', 'sources-row-data', (idx % 2 === 1) ? 'is-alt' : '', (activeRowId && id === activeRowId) ? 'is-active' : '' ].filter(Boolean).join(' '); const readOnlyLabel = tf('read_only', 'Read-only'); const lockIcon = src.readOnly ? `` : ''; const testLabel = tf('source_test', 'Test'); return `
${esc(name)}${lockIcon}${badgeWrap}
${esc(type)}
${esc(id)}
${esc(statusText)}
`; }).join(''); listEl.innerHTML = `
${header}${body}
`; }; const loadSources = async () => { if (!isPro || !proSourcesApiOk || window.__FR_IS_PRO === false) { return; } setStatus(tf('loading', 'Loading...')); listEl.innerHTML = `
${tf('loading', 'Loading...')}
`; let data = null; try { const res = await fetch(withBase('/api/pro/sources/list.php'), { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json' } }); data = await safeJson(res); } catch (e) { console.warn('Sources list failed', e); setStatus(e?.message || tf('error', 'Error'), 'danger'); } if (data && data.ok === true) { state.enabled = !!data.enabled; state.sources = Array.isArray(data.sources) ? data.sources : []; state.activeId = data.activeId || ''; setStatus(''); } else if (!state.sources.length) { state.sources = Array.isArray(sourcesCfg.sources) ? sourcesCfg.sources : []; } if (enabledToggle) enabledToggle.checked = !!state.enabled; pruneTestStatus(); renderList(); }; const runSourceTest = async (src) => { const id = String(src.id || ''); if (!id) return null; const current = getTestStatus(id); if (current.state === 'testing') { return null; } setTestStatus(id, { state: 'testing', message: '' }); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 25000); try { const res = await fetch(withBase('/api/pro/sources/test.php'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'Accept': 'application/json', }, body: JSON.stringify({ id }), signal: controller.signal, }); const data = await safeJson(res); const msg = data && data.message ? String(data.message) : ''; setTestStatus(id, { state: 'ok', message: msg }); return true; } catch (err) { const timedOut = err && err.name === 'AbortError'; const msg = timedOut ? tf('source_test_timeout', 'Test timed out.') : (err?.message || tf('source_test_error', 'Test failed')); setTestStatus(id, { state: 'error', message: msg }); showToast(msg, 5000); return false; } finally { clearTimeout(timer); } }; if (enabledToggle) { enabledToggle.checked = !!state.enabled; enabledToggle.addEventListener('change', async () => { const next = !!enabledToggle.checked; try { const res = await fetch(withBase('/api/pro/sources/save.php'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'Accept': 'application/json', }, body: JSON.stringify({ enabled: next }), }); const data = await safeJson(res); if (!data || data.ok !== true) { throw new Error(data?.error || 'Failed to save sources setting.'); } state.enabled = next; setStatus(tf('settings_updated_successfully', 'Settings updated successfully.')); await refreshSourceSelectorSafe('admin-sources-toggle'); } catch (e) { console.warn('Sources enabled save failed', e); enabledToggle.checked = !next; setStatus(e?.message || tf('error_updating_settings', 'Error updating settings'), 'danger'); } }); } if (refreshBtn) { refreshBtn.addEventListener('click', loadSources); } if (addBtn) { addBtn.addEventListener('click', () => { resetForm(); }); } if (resetBtn) { resetBtn.addEventListener('click', () => resetForm()); } if (sourceTypeEl) { sourceTypeEl.addEventListener('change', () => setType(sourceTypeEl.value)); } if (listEl) { listEl.addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if (!btn) return; const id = btn.getAttribute('data-id') || ''; const action = btn.getAttribute('data-action') || ''; const src = (state.sources || []).find(s => String(s.id || '') === id); if (!src) return; if (action === 'edit') { fillForm(src); return; } if (action === 'test') { runSourceTest(src); return; } if (action === 'delete') { const ok = window.confirm( `Delete source "${src.name || src.id}"? This does not remove any stored files.` ); if (!ok) return; (async () => { try { const res = await fetch(withBase('/api/pro/sources/delete.php'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'Accept': 'application/json', }, body: JSON.stringify({ id: src.id }), }); const data = await safeJson(res); if (!data || data.ok !== true) { throw new Error(data?.error || 'Failed to delete source.'); } resetForm(); await loadSources(); await refreshSourceSelectorSafe('admin-sources-delete'); } catch (err) { showToast(err?.message || tf('error', 'Error'), 'error'); } })(); } }); } if (saveBtn) { saveBtn.addEventListener('click', async () => { const id = (sourceIdEl?.value || '').trim(); const name = (sourceNameEl?.value || '').trim(); const type = (sourceTypeEl?.value || '').trim().toLowerCase(); const enabled = !!sourceEnabledEl?.checked; const readOnly = !!sourceReadOnlyEl?.checked; if (!id || !name || !type) { showToast(t('admin_source_required_fields'), 'error'); return; } const config = {}; if (type === 'local') { const path = (localPathEl?.value || '').trim(); if (path) { config.path = path; } } else if (type === 's3') { const bucket = (s3BucketEl?.value || '').trim(); if (!bucket) { showToast(t('admin_source_s3_bucket_required'), 'error'); return; } config.bucket = bucket; const region = (s3RegionEl?.value || '').trim(); const endpoint = (s3EndpointEl?.value || '').trim(); const prefix = (s3PrefixEl?.value || '').trim(); const accessKey = (s3AccessKeyEl?.value || '').trim(); const secretKey = (s3SecretKeyEl?.value || '').trim(); const sessionToken = (s3SessionTokenEl?.value || '').trim(); if (region) config.region = region; if (endpoint) config.endpoint = endpoint; if (prefix) config.prefix = prefix; if (accessKey) config.accessKey = accessKey; if (secretKey) config.secretKey = secretKey; if (sessionToken) config.sessionToken = sessionToken; config.pathStyle = !!s3PathStyleEl?.checked; } else if (type === 'sftp') { const host = (sftpHostEl?.value || '').trim(); const username = (sftpUsernameEl?.value || '').trim(); const password = (sftpPasswordEl?.value || '').trim(); const privateKey = (sftpPrivateKeyEl?.value || '').trim(); const privateKeyPassphrase = (sftpPrivateKeyPassEl?.value || '').trim(); if (!host || !username) { showToast(t('admin_source_sftp_host_required'), 'error'); return; } if (!password && !privateKey && !editingId) { showToast(t('admin_source_sftp_auth_required'), 'error'); return; } config.host = host; config.username = username; const port = parseInt(sftpPortEl?.value || '', 10); if (Number.isFinite(port) && port > 0) { config.port = port; } const root = (sftpRootEl?.value || '').trim(); if (root) config.root = root; if (password) config.password = password; if (privateKey) config.privateKey = privateKey; if (privateKeyPassphrase) config.privateKeyPassphrase = privateKeyPassphrase; } else if (type === 'ftp') { const host = (ftpHostEl?.value || '').trim(); const username = (ftpUsernameEl?.value || '').trim(); const password = (ftpPasswordEl?.value || '').trim(); if (!host || !username) { showToast(t('admin_source_ftp_host_required'), 'error'); return; } config.host = host; config.username = username; const port = parseInt(ftpPortEl?.value || '', 10); if (Number.isFinite(port) && port > 0) { config.port = port; } const root = (ftpRootEl?.value || '').trim(); if (root) config.root = root; if (password) config.password = password; config.ssl = !!ftpSslEl?.checked; config.passive = (ftpPassiveEl?.checked !== false); } else if (type === 'webdav') { const baseUrl = (webdavUrlEl?.value || '').trim(); const username = (webdavUsernameEl?.value || '').trim(); const password = (webdavPasswordEl?.value || '').trim(); const root = (webdavRootEl?.value || '').trim(); const verifyTls = (webdavVerifyTlsEl?.checked !== false); if (!baseUrl || !username) { showToast(t('admin_source_webdav_base_required'), 'error'); return; } if (!password && !editingId) { showToast(t('admin_source_webdav_password_required'), 'error'); return; } config.baseUrl = baseUrl; config.username = username; if (password) config.password = password; if (root) config.root = root; config.verifyTls = !!verifyTls; } else if (type === 'smb') { const host = (smbHostEl?.value || '').trim(); const share = (smbShareEl?.value || '').trim(); const username = (smbUsernameEl?.value || '').trim(); const password = (smbPasswordEl?.value || '').trim(); const domain = (smbDomainEl?.value || '').trim(); const version = (smbVersionEl?.value || '').trim(); const root = (smbRootEl?.value || '').trim(); if (!host || !share || !username) { showToast(t('admin_source_smb_host_required'), 'error'); return; } if (!password && !editingId) { showToast(t('admin_source_smb_password_required'), 'error'); return; } config.host = host; config.share = share; config.username = username; if (password) config.password = password; if (domain) config.domain = domain; if (version) config.version = version; if (root) config.root = root; } else if (type === 'gdrive') { const clientId = (gdriveClientIdEl?.value || '').trim(); const clientSecret = (gdriveClientSecretEl?.value || '').trim(); const refreshToken = (gdriveRefreshTokenEl?.value || '').trim(); const rootId = (gdriveRootIdEl?.value || '').trim(); const driveId = (gdriveDriveIdEl?.value || '').trim(); if (!clientId) { showToast(t('admin_source_gdrive_client_required'), 'error'); return; } if ((!clientSecret || !refreshToken) && !editingId) { showToast(t('admin_source_gdrive_secret_required'), 'error'); return; } config.clientId = clientId; if (clientSecret) config.clientSecret = clientSecret; if (refreshToken) config.refreshToken = refreshToken; if (rootId) config.rootId = rootId; if (driveId) config.driveId = driveId; } const payload = { source: { id, name, type, enabled, readOnly, config } }; const existingIdx = (state.sources || []).findIndex(s => String(s.id || '') === id); const isNewSource = existingIdx === -1; const optimisticEnabled = isNewSource ? false : ((state.sources[existingIdx]?.enabled) !== false); const optimisticSource = { id, name, type, enabled: optimisticEnabled, readOnly }; if (isNewSource) { state.sources = [...(state.sources || []), optimisticSource]; } else if (existingIdx >= 0) { state.sources[existingIdx] = { ...state.sources[existingIdx], ...optimisticSource }; } if (isNewSource || existingIdx >= 0) { setTestStatus(id, { state: 'testing', message: tf('saving_source_short', 'Saving...') }); } setSavingState(true, tf('saving_source', 'Saving source... this may take a moment.')); showToast(tf('saving_source_toast', 'Saving source...'), 2000); try { const res = await fetch(withBase('/api/pro/sources/save.php'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'Accept': 'application/json', }, body: JSON.stringify(payload), }); const data = await safeJson(res); if (!data || data.ok !== true) { throw new Error(data?.error || 'Failed to save source.'); } await loadSources(); if (data?.autoTested) { if (data.autoTestOk) { setTestStatus(id, { state: 'ok', message: '' }); } else { const errMsg = data.autoTestError || tf('source_test_error', 'Test failed'); setTestStatus(id, { state: 'error', message: errMsg }); const disabledNote = data.autoDisabled ? t('admin_source_test_left_disabled_note') : ''; showToast(t('admin_source_test_failed_detail', { error: errMsg, note: disabledNote }), 5000); } } else if (enabled) { const testOk = await runSourceTest({ id }); if (testOk === false) { const disablePayload = { source: { id, name, type, enabled: false, readOnly, config } }; try { const disableRes = await fetch(withBase('/api/pro/sources/save.php'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'Accept': 'application/json', }, body: JSON.stringify(disablePayload), }); const disableData = await safeJson(disableRes); if (!disableData || disableData.ok !== true) { throw new Error(disableData?.error || 'Failed to disable source after test failure.'); } showToast(t('admin_source_test_failed_left_disabled'), 'error'); await loadSources(); } catch (disableErr) { showToast(disableErr?.message || t('admin_source_test_failed_disable_error'), 'error'); } } } else { setTestStatus(id, { state: 'idle', message: '' }); } await refreshSourceSelectorSafe('admin-sources-save'); resetForm(); } catch (err) { if (isNewSource) { state.sources = (state.sources || []).filter(s => String(s.id || '') !== id); if (state.testStatus) { delete state.testStatus[id]; } renderList(); } else { setTestStatus(id, { state: 'error', message: err?.message || tf('error', 'Error') }); } setStatus(err?.message || tf('error', 'Error'), 'danger'); showToast(err?.message || tf('error', 'Error'), 'error'); } finally { setSavingState(false); } }); } resetForm(); loadSources(); } function onShareFolderToggle(row, checked) { const manage = qs(row, 'input[data-cap="manage"]'); const viewAll = qs(row, 'input[data-cap="view"]'); if (checked) { if (manage && !manage.checked) manage.checked = true; if (viewAll && !viewAll.checked) viewAll.checked = true; } enforceShareFolderRule(row); } function onShareFileToggle(row, checked) { if (!checked) return; 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); if (!hasView && !hasOwn && viewOwn) { viewOwn.checked = true; } } function onWriteToggle(row, checked) { 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; }); } /* === END: Folder Access helpers (merged + improved) === */ // Translate with fallback const tf = (key, fallback) => { const v = t(key); return (v && v !== key) ? v : fallback; }; function wireOidcDebugSnapshotButton(scope = document) { const btn = scope.querySelector('#oidcDebugSnapshotBtn'); const box = scope.querySelector('#oidcDebugSnapshot'); if (!btn || !box || btn.__wired) return; btn.__wired = true; btn.addEventListener('click', async () => { box.textContent = 'Loading snapshot…'; try { const res = await fetch('/api/admin/oidcDebugInfo.php', { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json', 'X-CSRF-Token': window.csrfToken || '' } }); const data = await safeJson(res).catch(err => { console.error('oidcDebugInfo HTTP error', err); return null; }); if (!data || data.success !== true) { const msg = (data && (data.error || data.message)) || t('admin_oidc_snapshot_failed'); box.textContent = msg; showToast(msg, 'error'); return; } box.textContent = JSON.stringify(data.info || data.data || data, null, 2); } catch (e) { console.error('oidcDebugInfo error', e); box.textContent = 'Error: ' + (e && e.message ? e.message : String(e)); showToast(t('admin_oidc_snapshot_failed'), 'error'); } }); } // --- tiny robust JSON helper --- async function safeJson(res) { const text = await res.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch (e) { /* ignore */ } if (!res.ok) { const msg = (body && (body.error || body.message)) || (text && text.trim()) || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; throw err; } return body ?? {}; } let originalAdminConfig = {}; function captureInitialAdminConfig() { const ht = document.getElementById("headerTitle"); originalAdminConfig = { headerTitle: ht ? ht.value.trim() : "", publishedUrl: (document.getElementById("publishedUrl")?.value || "").trim(), oidcProviderUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(), oidcClientId: (document.getElementById("oidcClientId")?.value || "").trim(), oidcClientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(), oidcDebugLogging: !!document.getElementById("oidcDebugLogging")?.checked, oidcRedirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim(), oidcAllowDemote: !!document.getElementById("oidcAllowDemote")?.checked, // UI is now “enable” toggles enableFormLogin: !!document.getElementById("enableFormLogin")?.checked, enableBasicAuth: !!document.getElementById("enableBasicAuth")?.checked, enableOIDCLogin: !!document.getElementById("enableOIDCLogin")?.checked, authBypass: !!document.getElementById("authBypass")?.checked, enableWebDAV: !!document.getElementById("enableWebDAV")?.checked, sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(), resumableChunkMb: (document.getElementById("resumableChunkMb")?.value || "").trim(), globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(), brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(), brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(), brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(), brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(), defaultLanguage: (document.getElementById("defaultLanguage")?.value || "").trim(), hoverPreviewMaxImageMb: (document.getElementById("hoverPreviewMaxImageMb")?.value || "").trim(), hoverPreviewMaxVideoMb: (document.getElementById("hoverPreviewMaxVideoMb")?.value || "").trim(), ffmpegPath: (document.getElementById("ffmpegPath")?.value || "").trim(), fileListSummaryDepth: (document.getElementById("fileListSummaryDepth")?.value || "").trim(), ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(), clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked, clamavExcludeDirs: (document.getElementById("clamavExcludeDirs")?.value || "").trim(), proSearchEnabled: !!document.getElementById("proSearchEnabled")?.checked, proSearchLimit: (document.getElementById("proSearchLimit")?.value || "").trim(), proAuditEnabled: !!document.getElementById("proAuditEnabled")?.checked, proAuditLevel: (document.getElementById("proAuditLevel")?.value || "").trim(), proAuditMaxFileMb: (document.getElementById("proAuditMaxFileMb")?.value || "").trim(), proAuditMaxFiles: (document.getElementById("proAuditMaxFiles")?.value || "").trim(), }; } function hasUnsavedChanges() { const o = originalAdminConfig; const getVal = id => (document.getElementById(id)?.value || "").trim(); const getChk = id => !!document.getElementById(id)?.checked; return ( getVal("headerTitle") !== o.headerTitle || getVal("publishedUrl") !== (o.publishedUrl || "") || getVal("oidcProviderUrl") !== o.oidcProviderUrl || getVal("oidcClientId") !== o.oidcClientId || getVal("oidcClientSecret") !== o.oidcClientSecret || getVal("oidcRedirectUri") !== o.oidcRedirectUri || getChk("oidcAllowDemote") !== o.oidcAllowDemote || getChk("oidcDebugLogging") !== o.oidcDebugLogging || // new enable-toggles getChk("enableFormLogin") !== o.enableFormLogin || getChk("enableBasicAuth") !== o.enableBasicAuth || getChk("enableOIDCLogin") !== o.enableOIDCLogin || getChk("authBypass") !== o.authBypass || getChk("enableWebDAV") !== o.enableWebDAV || getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize || getVal("resumableChunkMb") !== o.resumableChunkMb || getVal("globalOtpauthUrl") !== o.globalOtpauthUrl || getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") || getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") || getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") || getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "") || getVal("defaultLanguage") !== (o.defaultLanguage || "") || getVal("hoverPreviewMaxImageMb") !== (o.hoverPreviewMaxImageMb || "") || getVal("hoverPreviewMaxVideoMb") !== (o.hoverPreviewMaxVideoMb || "") || getVal("ffmpegPath") !== (o.ffmpegPath || "") || getVal("fileListSummaryDepth") !== (o.fileListSummaryDepth || "") || getVal("ignoreRegex") !== (o.ignoreRegex || "") || getChk("clamavScanUploads") !== o.clamavScanUploads || getVal("clamavExcludeDirs") !== (o.clamavExcludeDirs || "") || getChk("proSearchEnabled") !== o.proSearchEnabled || getVal("proSearchLimit") !== o.proSearchLimit || getChk("proAuditEnabled") !== o.proAuditEnabled || getVal("proAuditLevel") !== o.proAuditLevel || getVal("proAuditMaxFileMb") !== o.proAuditMaxFileMb || getVal("proAuditMaxFiles") !== o.proAuditMaxFiles ); } function showCustomConfirmModal(message) { return new Promise(resolve => { const modal = document.getElementById("customConfirmModal"); const msg = document.getElementById("confirmMessage"); const yes = document.getElementById("confirmYesBtn"); const no = document.getElementById("confirmNoBtn"); if (!modal || !msg || !yes || !no) { resolve(true); return; } msg.textContent = message; modal.style.display = "block"; function clean() { modal.style.display = "none"; yes.removeEventListener("click", onYes); no.removeEventListener("click", onNo); } function onYes() { clean(); resolve(true); } function onNo() { clean(); resolve(false); } yes.addEventListener("click", onYes); no.addEventListener("click", onNo); }); } function showProUpdateChoiceModal({ hasAuto }) { return new Promise(resolve => { let modal = document.getElementById("proUpdateChoiceModal"); if (!modal) { modal = document.createElement("div"); modal.id = "proUpdateChoiceModal"; modal.className = "modal"; modal.style.zIndex = "4000"; modal.style.display = "none"; modal.innerHTML = ` `; document.body.appendChild(modal); } const titleEl = document.getElementById("proUpdateChoiceTitle"); const msgEl = document.getElementById("proUpdateChoiceMessage"); const autoBtn = document.getElementById("proUpdateChoiceAutoBtn"); const manualBtn = document.getElementById("proUpdateChoiceManualBtn"); const cancelBtn = document.getElementById("proUpdateChoiceCancelBtn"); if (!titleEl || !msgEl || !autoBtn || !manualBtn || !cancelBtn) { resolve(null); return; } titleEl.textContent = "Pro update available"; msgEl.textContent = hasAuto ? "Choose how you'd like to get the latest Pro bundle." : "Save a license key to enable one-click download. You can still download manually."; autoBtn.disabled = !hasAuto; modal.style.display = "block"; const cleanup = () => { modal.style.display = "none"; autoBtn.removeEventListener("click", onAuto); manualBtn.removeEventListener("click", onManual); cancelBtn.removeEventListener("click", onCancel); }; const onAuto = () => { cleanup(); resolve("auto"); }; const onManual = () => { cleanup(); resolve("manual"); }; const onCancel = () => { cleanup(); resolve(null); }; autoBtn.addEventListener("click", onAuto); manualBtn.addEventListener("click", onManual); cancelBtn.addEventListener("click", onCancel); }); } function showTypedConfirmModal({ title, message, confirmText, placeholder }) { return new Promise(resolve => { let modal = document.getElementById("typedConfirmModal"); if (!modal) { modal = document.createElement("div"); modal.id = "typedConfirmModal"; modal.className = "modal"; modal.style.zIndex = "4000"; modal.style.display = "none"; modal.innerHTML = ` `; document.body.appendChild(modal); } const titleEl = document.getElementById("typedConfirmTitle"); const msgEl = document.getElementById("typedConfirmMessage"); const input = document.getElementById("typedConfirmInput"); const yes = document.getElementById("typedConfirmYesBtn"); const no = document.getElementById("typedConfirmNoBtn"); if (!titleEl || !msgEl || !input || !yes || !no) { resolve(false); return; } titleEl.textContent = title || "Confirm"; msgEl.textContent = message || ""; input.value = ""; input.placeholder = placeholder || ""; yes.disabled = true; modal.style.display = "block"; input.focus(); const onInput = () => { yes.disabled = (input.value !== confirmText); }; const cleanup = () => { modal.style.display = "none"; input.removeEventListener("input", onInput); yes.removeEventListener("click", onYes); no.removeEventListener("click", onNo); }; const onYes = () => { cleanup(); resolve(true); }; const onNo = () => { cleanup(); resolve(false); }; input.addEventListener("input", onInput); yes.addEventListener("click", onYes); no.addEventListener("click", onNo); }); } const SECTION_ANIM_MS = 160; function clearSectionTimer(cnt) { if (cnt && cnt.__closeTimer) { clearTimeout(cnt.__closeTimer); cnt.__closeTimer = null; } if (cnt && cnt.__focusTimer) { clearTimeout(cnt.__focusTimer); cnt.__focusTimer = null; } } function openSectionContent(cnt) { if (!cnt) return; clearSectionTimer(cnt); cnt.style.display = "block"; cnt.classList.remove("is-closing"); requestAnimationFrame(() => { cnt.classList.add("is-open"); }); } function closeSectionContent(cnt) { if (!cnt) return; clearSectionTimer(cnt); cnt.classList.remove("is-open"); cnt.classList.add("is-closing"); cnt.__closeTimer = setTimeout(() => { if (cnt.classList.contains("is-closing")) { cnt.style.display = "none"; cnt.classList.remove("is-closing"); } cnt.__closeTimer = null; }, SECTION_ANIM_MS); } function setSectionContentImmediate(cnt, open) { if (!cnt) return; clearSectionTimer(cnt); if (open) { cnt.style.display = "block"; cnt.classList.add("is-open"); cnt.classList.remove("is-closing"); } else { cnt.style.display = "none"; cnt.classList.remove("is-open", "is-closing"); } } function focusSectionIntoView(hdr, cnt) { if (!hdr || !cnt) return; const scroller = hdr.closest(".modal-content") || document.scrollingElement || document.documentElement; if (!scroller) { try { hdr.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" }); } catch (e) { hdr.scrollIntoView(); } return; } const padding = 12; const scrollerRect = scroller.getBoundingClientRect(); const headerRect = hdr.getBoundingClientRect(); const targetTop = scroller.scrollTop + (headerRect.top - scrollerRect.top) - padding; const nextTop = Math.max(0, targetTop); if (Math.abs(nextTop - scroller.scrollTop) < 1) return; try { if (typeof scroller.scrollTo === "function") { scroller.scrollTo({ top: nextTop, behavior: "smooth" }); setTimeout(() => { if (Math.abs(scroller.scrollTop - nextTop) > 1) { scroller.scrollTop = nextTop; } }, 40); return; } } catch (e) { // Fallback for browsers that don't accept scroll options. } scroller.scrollTop = nextTop; } function scheduleSectionFocus(hdr, cnt) { if (!hdr || !cnt) return; clearSectionTimer(cnt); const focusNow = () => { if (cnt.style.display === "none") return; focusSectionIntoView(hdr, cnt); }; requestAnimationFrame(() => { requestAnimationFrame(() => { focusNow(); cnt.__focusTimer = setTimeout(() => { if (cnt.classList.contains("is-open") && cnt.style.display !== "none") { focusSectionIntoView(hdr, cnt); } cnt.__focusTimer = null; }, SECTION_ANIM_MS + 40); }); }); } function toggleSection(id) { const hdr = document.getElementById(id + "Header"); const cnt = document.getElementById(id + "Content"); if (!hdr || !cnt) return; const isCollapsedNow = hdr.classList.toggle("collapsed"); if (isCollapsedNow) { closeSectionContent(cnt); } else { openSectionContent(cnt); scheduleSectionFocus(hdr, cnt); } if (!isCollapsedNow && id === "shareLinks") { loadShareLinksSection(); cnt.dataset.loaded = "1"; } } function normalizeAdminSearchText(value) { return String(value || "") .toLowerCase() .replace(/\s+/g, " ") .trim(); } function wireAdminPanelSearch(sectionIds) { const input = document.getElementById("adminSettingsSearch"); const emptyState = document.getElementById("adminSettingsSearchEmpty"); const wrap = document.getElementById("adminSettingsSearchWrap"); const toggleBtn = document.getElementById("adminSearchToggle"); const clearBtn = document.getElementById("adminSearchClear"); if (!input || !Array.isArray(sectionIds)) return; const ids = sectionIds.filter(Boolean); const setOpen = (open, opts = {}) => { if (!wrap || !toggleBtn) return; wrap.classList.toggle("is-collapsed", !open); toggleBtn.setAttribute("aria-expanded", open ? "true" : "false"); if (open && opts.focus) { input.focus(); } }; const updateToggleState = () => { const hasQuery = !!normalizeAdminSearchText(input.value); if (toggleBtn) { toggleBtn.classList.toggle("is-active", hasQuery); } if (clearBtn) { clearBtn.hidden = !hasQuery; clearBtn.disabled = !hasQuery; } }; const restoreState = () => { ids.forEach(id => { const hdr = document.getElementById(id + "Header"); const cnt = document.getElementById(id + "Content"); if (!hdr || !cnt) return; const saved = hdr.dataset.prevCollapsed; hdr.style.display = ""; cnt.style.display = ""; if (saved !== undefined) { const wasCollapsed = saved === "1"; hdr.classList.toggle("collapsed", wasCollapsed); setSectionContentImmediate(cnt, !wasCollapsed); delete hdr.dataset.prevCollapsed; } }); if (emptyState) emptyState.style.display = "none"; }; const applyFilter = () => { const query = normalizeAdminSearchText(input.value); if (!query) { restoreState(); updateToggleState(); return; } let matches = 0; ids.forEach(id => { const hdr = document.getElementById(id + "Header"); const cnt = document.getElementById(id + "Content"); if (!hdr || !cnt) return; if (hdr.dataset.prevCollapsed === undefined) { hdr.dataset.prevCollapsed = hdr.classList.contains("collapsed") ? "1" : "0"; } const haystack = normalizeAdminSearchText(hdr.textContent + " " + cnt.textContent); const match = haystack.includes(query); hdr.style.display = match ? "" : "none"; setSectionContentImmediate(cnt, match); if (match) { hdr.classList.remove("collapsed"); matches += 1; if (id === "shareLinks" && !cnt.dataset.loaded) { loadShareLinksSection(); cnt.dataset.loaded = "1"; } } }); if (emptyState) emptyState.style.display = matches ? "none" : "block"; updateToggleState(); }; const clearSearch = (opts = {}) => { input.value = ""; applyFilter(); if (opts.collapse) { setOpen(false); } if (opts.focus) { input.focus(); } }; if (clearBtn && !clearBtn.__wired) { clearBtn.__wired = true; clearBtn.addEventListener("click", (e) => { e.preventDefault(); clearSearch({ focus: true }); }); } if (toggleBtn && wrap && !toggleBtn.__wired) { toggleBtn.__wired = true; toggleBtn.addEventListener("click", () => { const isCollapsed = wrap.classList.contains("is-collapsed"); if (isCollapsed) { setOpen(true, { focus: true }); return; } const hasQuery = !!normalizeAdminSearchText(input.value); if (hasQuery) { clearSearch({ collapse: true }); return; } setOpen(false); }); } input.addEventListener("input", applyFilter); const initialQuery = normalizeAdminSearchText(input.value); if (initialQuery) { setOpen(true); applyFilter(); } else { setOpen(false); updateToggleState(); } } export function initProBundleInstaller(options = {}) { try { const fileInput = document.getElementById('proBundleFile'); const btn = document.getElementById('btnInstallProBundle'); const dlBtn = document.getElementById('btnDownloadProBundle'); const statusEl = document.getElementById('proBundleStatus'); const updatesExpired = !!options.updatesExpired; const updatesUntilLabel = options.updatesUntil ? String(options.updatesUntil) : ''; if (!fileInput || !btn || !statusEl) return; // Allow names like: FileRisePro_v1.0.0.zip or FileRisePro-1.0.0.zip const PRO_ZIP_NAME_RE = /^FileRisePro[_-]v?[0-9]+\.[0-9]+\.[0-9]+\.zip$/i; btn.addEventListener('click', async () => { const file = fileInput.files && fileInput.files[0]; if (!file) { statusEl.textContent = 'Choose a FileRise Pro .zip bundle first.'; statusEl.className = 'small text-danger'; return; } const name = file.name || ''; if (!PRO_ZIP_NAME_RE.test(name)) { statusEl.textContent = 'Bundle must be named like "FileRisePro_v1.0.0.zip".'; statusEl.className = 'small text-danger'; return; } if (updatesExpired) { const when = updatesUntilLabel ? ` on ${updatesUntilLabel}` : ''; const ok = await showCustomConfirmModal( `Updates expired${when}. Installing a newer Pro bundle will deactivate Pro. Continue only if this bundle is eligible for your license.` ); if (!ok) { return; } } const formData = new FormData(); formData.append('bundle', file); statusEl.textContent = 'Uploading and installing Pro bundle...'; statusEl.className = 'small text-muted'; try { const resp = await fetch('/api/admin/installProBundle.php', { method: 'POST', headers: { 'X-CSRF-Token': window.csrfToken || '' }, body: formData }); let data = null; try { data = await resp.json(); } catch (_) { // ignore JSON parse errors; handled below } if (!resp.ok || !data || !data.success) { const msg = data && data.error ? data.error : `HTTP ${resp.status}`; statusEl.textContent = 'Install failed: ' + msg; statusEl.className = 'small text-danger'; return; } // --- NEW: ask the server what version is now active via getConfig.php --- let finalVersion = ''; try { const cfgRes = await fetch('/api/admin/getConfig.php?ts=' + Date.now(), { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-store' } }); const cfg = await safeJson(cfgRes).catch(() => null); const cfgVersion = cfg && cfg.pro && cfg.pro.version; if (cfgVersion) { finalVersion = String(cfgVersion); } } catch (e) { // If this fails, just fall back to whatever installProBundle gave us. console.warn('Failed to refresh config after Pro bundle install', e); } if (!finalVersion && data.proVersion) { finalVersion = String(data.proVersion); } const versionText = finalVersion ? ` (version ${finalVersion})` : ''; statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.'; statusEl.className = 'small text-success'; // Clear file input so repeat installs feel "fresh" try { fileInput.value = ''; } catch (_) { } // Keep existing behavior: refresh any admin config in the header, etc. if (typeof loadAdminConfigFunc === 'function') { loadAdminConfigFunc(); } setTimeout(() => { window.location.reload(); }, 800); } catch (e) { statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e)); statusEl.className = 'small text-danger'; } }); if (dlBtn && !dlBtn.__wired) { dlBtn.__wired = true; dlBtn.addEventListener('click', async () => { if (updatesExpired) { const when = updatesUntilLabel ? ` on ${updatesUntilLabel}` : ''; statusEl.textContent = `Updates expired${when}. Renew to download newer Pro bundles.`; statusEl.className = 'small text-warning'; return; } const ok = await showCustomConfirmModal( "Download and install the latest Pro bundle from filerise.net? This will overwrite the current Pro files." ); if (!ok) return; dlBtn.disabled = true; statusEl.textContent = 'Downloading and installing latest Pro bundle...'; statusEl.className = 'small text-muted'; try { const resp = await fetch('/api/admin/downloadProBundle.php', { method: 'POST', headers: { 'X-CSRF-Token': window.csrfToken || '' }, credentials: 'include' }); let data = null; try { data = await resp.json(); } catch (_) { data = null; } if (!resp.ok || !data || !data.success) { const msg = data && (data.error || data.message) ? (data.error || data.message) : `HTTP ${resp.status}`; statusEl.textContent = 'Download/install failed: ' + msg; statusEl.className = 'small text-danger'; return; } const finalVersion = data.proVersion ? String(data.proVersion) : ''; const versionText = finalVersion ? ` (version ${finalVersion})` : ''; statusEl.textContent = 'Pro bundle installed' + versionText + '. Reloading...'; statusEl.className = 'small text-success'; if (typeof loadAdminConfigFunc === 'function') { loadAdminConfigFunc(); } setTimeout(() => { window.location.reload(); }, 800); } catch (e) { statusEl.textContent = 'Download/install failed: ' + (e && e.message ? e.message : String(e)); statusEl.className = 'small text-danger'; } finally { dlBtn.disabled = false; } }); } } catch (e) { console.warn('Failed to init Pro bundle installer', e); } } let __userFlagsCacheHub = null; let __userMetaCache = {}; // username -> { isAdmin } async function getUserFlagsCacheForHub() { if (!__userFlagsCacheHub) { __userFlagsCacheHub = await fetchAllUserFlags(); } return __userFlagsCacheHub; } function updateUserMetaCache(list) { __userMetaCache = {}; (list || []).forEach(u => { if (!u || !u.username) return; __userMetaCache[u.username] = { isAdmin: isAdminUser(u) }; }); } async function renderUserHubFlagsForSelected(modal) { const flagsHost = modal.querySelector('#adminUserHubFlagsRow'); const selectEl = modal.querySelector('#adminUserHubSelect'); if (!flagsHost || !selectEl) return; const username = (selectEl.value || "").trim(); if (!username) { flagsHost.innerHTML = `
${tf("select_user_for_flags", "Select a user above to view account-level switches.")}
`; return; } const flagsCache = await getUserFlagsCacheForHub(); const flags = flagsCache[username] || {}; const meta = __userMetaCache[username] || {}; const isAdmin = !!meta.isAdmin; const disabledAttr = isAdmin ? 'disabled data-admin="1" title="Admin: full access"' : ''; const adminNote = isAdmin ? `(${tf("admin_full_access", "Admin: full access")})` : ''; flagsHost.innerHTML = `
${t("user")} ${t("read_only")} ${t("disable_upload")} ${t("can_share")} ${t("bypass_ownership")}
${escapeHTML(username)}${adminNote}
${isAdmin ? tf("admin_flags_info", "Admins already have full access. These switches are disabled.") : tf("user_flags_inline_help", "Changes here are saved immediately for this user.")} `; // Admin row is read-only if (isAdmin) return; const row = flagsHost.querySelector('tr[data-username]'); if (!row) return; const checkboxes = row.querySelectorAll('input[type="checkbox"][data-flag]'); const getFlagsFromRow = () => { const get = (k) => { const el = row.querySelector(`input[data-flag="${k}"]`); return !!(el && el.checked); }; return { username, readOnly: get("readOnly"), disableUpload: get("disableUpload"), canShare: get("canShare"), bypassOwnership: get("bypassOwnership") }; }; const saveFlags = async () => { const permissions = [getFlagsFromRow()]; try { const res = await sendRequest( "/api/updateUserPermissions.php", "PUT", { permissions }, { "X-CSRF-Token": window.csrfToken } ); if (!res || res.success === false) { const msg = (res && (res.error || res.message)) || tf("error_updating_permissions", "Error updating permissions"); showToast(msg, "error"); return; } // keep local cache in sync const flagsCache = await getUserFlagsCacheForHub(); flagsCache[username] = permissions[0]; showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); } catch (err) { console.error("save inline flags error", err); showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } }; checkboxes.forEach(cb => { cb.addEventListener("change", () => { saveFlags(); }); }); } export function openAdminUserHubModal() { const isDark = document.body.classList.contains("dark-mode"); const overlayBg = isDark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const contentBg = isDark ? "var(--fr-surface-dark)" : "#fff"; const contentFg = isDark ? "#e0e0e0" : "#000"; const borderCol = isDark ? "var(--fr-border-dark)" : "#ccc"; // Local helper so we ALWAYS see something (toast or alert) const safeToast = (msg, type) => { try { if (typeof showToast === "function") { showToast(msg, 7000); } else { alert(msg); } } catch (e) { console.error("showToast failed, falling back to alert", e); alert(msg); } }; let modal = document.getElementById("adminUserHubModal"); if (!modal) { modal = document.createElement("div"); modal.id = "adminUserHubModal"; modal.style.cssText = ` position:fixed; inset:0; background:${overlayBg}; display:flex; align-items:center; justify-content:center; z-index:9999; `; modal.innerHTML = ` `; document.body.appendChild(modal); const closeBtn = modal.querySelector("#closeAdminUserHub"); if (closeBtn) { closeBtn.addEventListener("click", () => { modal.style.display = "none"; }); } // ESC closes modal document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.style.display === "flex") { modal.style.display = "none"; } }); const selectEl = modal.querySelector("#adminUserHubSelect"); const refreshBtn = modal.querySelector("#adminUserHubRefresh"); const addForm = modal.querySelector("#adminUserHubAddForm"); const addBtn = modal.querySelector("#adminUserHubAddBtn"); const addCard = modal.querySelector("#adminUserHubAddCard"); const delBtn = modal.querySelector("#adminUserHubDeleteBtn"); const pwBtn = modal.querySelector("#adminUserHubSavePassword"); const newUserInput = modal.querySelector("#adminUserHubNewUsername"); const newPassInput = modal.querySelector("#adminUserHubAddPassword"); const newAdminInput = modal.querySelector("#adminUserHubIsAdmin"); const resetNewPwInput = modal.querySelector("#adminUserHubNewPassword"); const resetConfPwInput = modal.querySelector("#adminUserHubConfirmPassword"); const getSelectedUser = () => { return (selectEl && selectEl.value) ? selectEl.value.trim() : ""; }; if (refreshBtn && selectEl) { refreshBtn.addEventListener("click", async () => { await populateAdminUserHubSelect(selectEl, updateUserMetaCache); await renderUserHubFlagsForSelected(modal); }); } // "Add user" button toggles the dropdown card under the button if (addBtn && addCard && newUserInput) { addCard.style.display = "none"; addBtn.addEventListener("click", (e) => { e.stopPropagation(); const isHidden = addCard.style.display === "none" || addCard.style.display === ""; addCard.style.display = isHidden ? "block" : "none"; if (isHidden) { newUserInput.focus(); } }); // Clicking outside of the addCard closes it document.addEventListener("click", (e) => { if (!modal.contains(e.target)) return; if ( addCard.style.display === "block" && !addCard.contains(e.target) && !addBtn.contains(e.target) ) { addCard.style.display = "none"; } }); } // Inline "Add user" form WITH backend error -> toast (handles 422) if (addForm && newUserInput && newPassInput && newAdminInput && selectEl) { addForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = newUserInput.value.trim(); const password = newPassInput.value.trim(); const isAdmin = !!newAdminInput.checked; if (!username || !password) { safeToast("Username and password are required!", "error"); return; } try { const resp = await fetch("/api/addUser.php", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken || "" }, body: JSON.stringify({ username, password, isAdmin }) }); let data = null; try { data = await resp.json(); } catch (e) { // non-JSON or empty; leave as null } const isError = !resp.ok || !data || (data.ok === false) || (data.success === false); if (isError) { const msg = (data && (data.error || data.message)) || (resp.status === 422 ? "Could not create user. Please check username/password." : "Error: Could not add user"); console.error("Add user failed –", resp.status, data); safeToast(msg, "error"); return; } // success safeToast("User added successfully!"); newUserInput.value = ""; newPassInput.value = ""; newAdminInput.checked = false; // hide dropdown after successful create if (addCard) { addCard.style.display = "none"; } await populateAdminUserHubSelect(selectEl, updateUserMetaCache); selectEl.value = username; if (typeof __userFlagsCacheHub !== "undefined") { __userFlagsCacheHub = null; } await renderUserHubFlagsForSelected(modal); } catch (err) { console.error("Add user error", err); const msg = err && err.message ? err.message : "Network error while creating user."; safeToast(msg, "error"); } }); } // Delete user if (delBtn && selectEl) { delBtn.addEventListener("click", async () => { const username = getSelectedUser(); if (!username) { safeToast("Please select a user first.", "error"); return; } const current = (localStorage.getItem("username") || "").trim(); if (current && current === username) { safeToast( "You cannot delete the account you are currently logged in as.", "error" ); return; } const ok = await showCustomConfirmModal( `Are you sure you want to delete user "${username}"?` ); if (!ok) return; try { const res = await sendRequest( "/api/removeUser.php", "POST", { username }, { "X-CSRF-Token": window.csrfToken || "" } ); if (!res || res.success === false) { const msg = (res && (res.error || res.message)) || "Error: Could not remove user"; safeToast(msg, "error"); return; } safeToast("User removed successfully!"); if (typeof __userFlagsCacheHub !== "undefined") { __userFlagsCacheHub = null; } await populateAdminUserHubSelect(selectEl, updateUserMetaCache); await renderUserHubFlagsForSelected(modal); } catch (err) { console.error(err); const msg = err && err.message ? err.message : "Error: Could not remove user"; safeToast(msg, "error"); } }); } // Reset password for selected user (admin) if (pwBtn && resetNewPwInput && resetConfPwInput && selectEl) { pwBtn.addEventListener("click", async () => { if (window.__FR_DEMO__) { safeToast("Password changes are disabled on the public demo."); return; } const username = getSelectedUser(); if (!username) { safeToast("Please select a user first.", "error"); return; } const newPw = resetNewPwInput.value.trim(); const conf = resetConfPwInput.value.trim(); if (!newPw || !conf) { safeToast("Please fill in both password fields.", "error"); return; } if (newPw !== conf) { safeToast("New passwords do not match.", "error"); return; } try { const res = await sendRequest( "/api/admin/changeUserPassword.php", "POST", { username, newPassword: newPw }, { "X-CSRF-Token": window.csrfToken || "" } ); // Handle both legacy {success:false} and new {ok:false,error:...} if (!res || res.success === false || res.ok === false) { const msg = (res && (res.error || res.message)) || "Error changing password. Password must be at least 6 characters."; safeToast(msg, "error"); return; } safeToast("Password updated successfully."); resetNewPwInput.value = ""; resetConfPwInput.value = ""; } catch (err) { // If sendRequest throws on non-2xx, e.g. 422, surface backend JSON error console.error("Change password failed –", err.status, err.data || err); const msg = (err && err.data && (err.data.error || err.data.message)) || (err && err.message) || "Error changing password. Password must be at least 6 characters."; safeToast(msg, "error"); } }); } // When user selection changes, refresh inline flags row if (selectEl) { selectEl.addEventListener("change", () => { renderUserHubFlagsForSelected(modal); }); } // Expose for later calls to re-populate modal.__populate = async () => { const sel = modal.querySelector("#adminUserHubSelect"); if (sel) { await populateAdminUserHubSelect(sel, updateUserMetaCache); if (typeof __userFlagsCacheHub !== "undefined") { __userFlagsCacheHub = null; } await renderUserHubFlagsForSelected(modal); } }; } else { // Update colors/theme if already exists modal.style.background = overlayBg; const content = modal.querySelector(".modal-content"); if (content) { content.style.background = contentBg; content.style.color = contentFg; content.style.border = `1px solid ${borderCol}`; } } modal.style.display = "flex"; if (modal.__populate) { modal.__populate(); } } function loadShareLinksSection() { const container = document.getElementById("shareLinksList") || document.getElementById("shareLinksContent"); if (!container) return; container.textContent = t("loading") + "..."; function fetchMeta(fileName) { return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, { credentials: "include" }) .then(resp => (resp.ok ? resp.json() : {})) .catch(() => ({})); } Promise.all([ fetchMeta("share_folder_links.json"), fetchMeta("share_links.json") ]) .then(([folders, files]) => { const esc = (val) => escapeHTML(val == null ? "" : String(val)); const hasAny = Object.keys(folders).length || Object.keys(files).length; if (!hasAny) { container.innerHTML = `

${t("no_shared_links_available")}

`; return; } let html = `
${t("folder_shares")}
${t("file_shares")}
`; container.innerHTML = html; container.querySelectorAll(".delete-share").forEach(btn => { btn.addEventListener("click", evt => { evt.preventDefault(); const token = btn.dataset.key; const sourceId = btn.dataset.sourceId || ""; const isFolder = btn.dataset.type === "folder"; const endpoint = isFolder ? "/api/folder/deleteShareFolderLink.php" : "/api/file/deleteShareLink.php"; const csrfToken = (document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken || ""); const body = new URLSearchParams({ token }); if (sourceId) { body.set("sourceId", sourceId); } fetch(endpoint, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRF-Token": csrfToken }, body }) .then(res => { if (!res.ok) { if (res.status === 403) { // Optional: nicer message when CSRF/session is bad showToast(t('admin_share_delete_forbidden'), 'error'); } return Promise.reject(res); } return res.json(); }) .then(json => { if (json.success) { showToast(t("share_deleted_successfully")); loadShareLinksSection(); } else { showToast(t("error_deleting_share") + ": " + (json.error || ""), "error"); } }) .catch(err => { console.error("Delete error:", err); showToast(t("error_deleting_share"), "error"); }); }); }); }) .catch(err => { console.error("loadShareLinksSection error:", err); container.textContent = t("error_loading_share_links"); }); } export function openAdminPanel() { fetch("/api/admin/getConfig.php", { credentials: "include" }) .then(r => r.json()) .then(config => { const rawHeaderTitle = (typeof config.header_title === 'string') ? config.header_title : ''; window.headerTitle = rawHeaderTitle; window.currentOIDCConfig = window.currentOIDCConfig || {}; if (config.oidc && typeof config.oidc === 'object') { Object.assign(window.currentOIDCConfig, config.oidc); } if (config.globalOtpauthUrl) { window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl; } const dark = document.body.classList.contains("dark-mode"); const proInfo = config.pro || {}; const isPro = !!proInfo.active; window.__FR_IS_PRO = isPro; const headerDisplayTitle = resolveHeaderTitle(rawHeaderTitle, isPro); const h = document.querySelector(".header-title h1"); if (h) h.textContent = headerDisplayTitle; document.title = headerDisplayTitle; const proType = proInfo.type || ''; const proEmail = proInfo.email || ''; const proVersion = proInfo.version || 'not installed'; const proApiLevel = Number(proInfo.apiLevel || 0); const proBuildEpoch = Number(proInfo.buildEpoch || 0); const proSearchApiOk = isPro && proApiLevel >= PRO_API_LEVELS.search; const proAuditApiOk = isPro && proApiLevel >= PRO_API_LEVELS.audit; const proSourcesApiOk = isPro && proApiLevel >= PRO_API_LEVELS.sources; const proSearchOptOut = !!(config.proSearch && config.proSearch.optOut); const proLicense = proInfo.license || ''; // New: richer license metadata from FR_PRO_INFO / backend const proPlan = proInfo.plan || ''; // e.g. "early_supporter_1x", "personal_yearly" const proUpdatesUntil = proInfo.updatesUntil || proInfo.expiresAt || ''; // ISO timestamp string or "" const proInstanceId = proInfo.instanceId || ''; const proPrimaryAdmin = Object.prototype.hasOwnProperty.call(proInfo, 'primaryAdmin') ? !!proInfo.primaryAdmin : true; const proMaxMajor = ( typeof proInfo.maxMajor === 'number' ? proInfo.maxMajor : (proInfo.maxMajor ? Number(proInfo.maxMajor) : null) ); const proSearchCfg = (config.proSearch && typeof config.proSearch === 'object') ? config.proSearch : {}; const updatesExpired = (() => { if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) { return false; } if (!proUpdatesUntil) { return false; } const ts = Date.parse(proUpdatesUntil); return Number.isFinite(ts) && ts < Date.now(); })(); const proSearchExplicitDisabled = Object.prototype.hasOwnProperty.call(proSearchCfg, 'enabled') && !proSearchCfg.enabled; const proSearchOptOutEffective = proSearchOptOut || proSearchExplicitDisabled; let proSearchEnabled = proSearchApiOk && !!proSearchCfg.enabled; // Auto-enable when Pro API level supports Search Everywhere unless explicitly opted out/disabled or env locked if (proSearchApiOk && !proSearchOptOutEffective) { proSearchEnabled = true; } const proSearchDefaultLimit = Math.max( 1, Math.min(200, parseInt(proSearchCfg.defaultLimit || 50, 10) || 50) ); const proSearchLocked = !!proSearchCfg.lockedByEnv; const proAuditCfg = (config.proAudit && typeof config.proAudit === 'object') ? config.proAudit : {}; const proAuditAvailable = !!proAuditCfg.available && proAuditApiOk; const proAuditEnabled = (isPro && proAuditAvailable) ? !!proAuditCfg.enabled : false; const proAuditLevelRaw = (typeof proAuditCfg.level === 'string') ? proAuditCfg.level : 'verbose'; const proAuditLevel = (proAuditLevelRaw === 'standard' || proAuditLevelRaw === 'verbose') ? proAuditLevelRaw : 'verbose'; const proAuditMaxFileMb = Math.max(10, parseInt(proAuditCfg.maxFileMb || 200, 10) || 200); const proAuditMaxFiles = Math.max(1, Math.min(10, parseInt(proAuditCfg.maxFiles || 10, 10) || 10)); const sourcesCfg = (config.storageSources && typeof config.storageSources === 'object') ? config.storageSources : {}; const sourcesEnabled = !!sourcesCfg.enabled; const showSourcesSection = true; const brandingCfg = config.branding || {}; const brandingCustomLogoUrl = brandingCfg.customLogoUrl || ""; const brandingHeaderBgLight = brandingCfg.headerBgLight || ""; const brandingHeaderBgDark = brandingCfg.headerBgDark || ""; const brandingFooterHtml = brandingCfg.footerHtml || ""; const displayCfg = (config.display && typeof config.display === 'object') ? config.display : {}; const ffmpegPathCfg = (typeof config.ffmpegPath === 'string') ? config.ffmpegPath : ''; const ffmpegPathEffective = (typeof config.ffmpegPathEffective === 'string') ? config.ffmpegPathEffective : ''; const ffmpegPathLockedByEnv = !!config.ffmpegPathLockedByEnv; const ffmpegPathValue = ffmpegPathLockedByEnv ? ffmpegPathEffective : ffmpegPathCfg; const ffmpegHelpDefault = 'Used for video thumbnail generation. Leave blank to use ffmpeg from PATH.'; const ffmpegHelpLocked = 'Controlled by container env FR_FFMPEG_PATH. Change it in your Docker/host env.'; const ignoreRegexCfg = (typeof config.ignoreRegex === 'string') ? config.ignoreRegex : ''; const ignoreRegexEffective = (typeof config.ignoreRegexEffective === 'string') ? config.ignoreRegexEffective : ''; const ignoreRegexLockedByEnv = !!config.ignoreRegexLockedByEnv; const ignoreRegexValue = ignoreRegexLockedByEnv ? ignoreRegexEffective : ignoreRegexCfg; const supportedLanguages = [ { code: 'en', labelKey: 'english', fallback: 'English' }, { code: 'es', labelKey: 'spanish', fallback: 'Español' }, { code: 'fr', labelKey: 'french', fallback: 'Français' }, { code: 'de', labelKey: 'german', fallback: 'Deutsch' }, { code: 'pl', labelKey: 'polish', fallback: 'Polski' }, { code: 'ru', labelKey: 'russian', fallback: 'Русский' }, { code: 'ja', labelKey: 'japanese', fallback: '日本語' }, { code: 'zh-CN', labelKey: 'chinese_simplified', fallback: '简体中文' }, ]; const defaultLanguage = supportedLanguages.some(lang => lang.code === displayCfg.defaultLanguage) ? displayCfg.defaultLanguage : 'en'; const defaultLanguageOptions = supportedLanguages.map(({ code, labelKey, fallback }) => { const label = ((typeof t === 'function' ? t(labelKey) : '') || fallback).replace(/\"/g, '"'); const selected = code === defaultLanguage ? ' selected' : ''; return ``; }).join(''); const hoverPreviewMaxImageMb = Math.max( 1, Math.min(50, parseInt(displayCfg.hoverPreviewMaxImageMb || 8, 10) || 8) ); const hoverPreviewMaxVideoMb = Math.max( 1, Math.min(2048, parseInt(displayCfg.hoverPreviewMaxVideoMb || 200, 10) || 200) ); const rawSummaryDepth = parseInt(displayCfg.fileListSummaryDepth, 10); const fileListSummaryDepth = Math.max( 0, Math.min(10, Number.isFinite(rawSummaryDepth) ? rawSummaryDepth : 2) ); const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const inner = ` background:${dark ? "#2c2c2c" : "#fff"}; color:${dark ? "#e0e0e0" : "#000"}; padding:20px; max-width:1100px; width:50%; position:relative; max-height:90vh; overflow:auto; border:1px solid ${dark ? "#555" : "#ccc"}; `; let mdl = document.getElementById("adminPanelModal"); if (!mdl) { mdl = document.createElement("div"); mdl.id = "adminPanelModal"; mdl.style.cssText = ` position:fixed; top:0; left:0; width:100vw; height:100vh; background:${bg}; display:flex; justify-content:center; align-items:center; z-index:3000; `; const sections = [ { id: "userManagement", label: tf("users_access", "Users & Access") }, { id: "headerSettings", label: tf("appearance_ui", "Appearance, UI & Indexing") }, { id: "loginOptions", label: tf("auth_webdav", "Auth & WebDAV (OIDC/TOTP)") }, { id: "upload", label: tf("uploads_antivirus", "Uploads & Antivirus") }, { id: "shareLinks", label: tf("sharing_links", "Sharing & Links") }, { id: "network", label: tf("network_proxy", "Network & Proxy") }, { id: "encryption", label: tf("encryption_at_rest", "Encryption at rest") }, { id: "onlyoffice", label: "ONLYOFFICE" }, { id: "storage", label: tf("storage_usage", "Storage / Disk Usage") } ]; if (showSourcesSection) { const sourcesLabel = !isPro ? `${tf("sources", "Sources")}Pro` : tf("sources", "Sources"); sections.push({ id: "sources", label: sourcesLabel }); } sections.push( { id: "proFeatures", label: "Pro Features" }, { id: "pro", label: "FileRise Pro" }, { id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Thanks / Sponsor / Donations") : "Thanks / Sponsor / Donations") } ); const sectionIds = sections.map(sec => sec.id); mdl.innerHTML = ` `; document.body.appendChild(mdl); document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); wireAdminPanelSearch(sectionIds); sectionIds.filter(Boolean).forEach(id => { const headerEl = document.getElementById(id + "Header"); if (!headerEl || headerEl.__wired) return; headerEl.__wired = true; headerEl.addEventListener("click", () => toggleSection(id)); }); document.getElementById("userManagementContent").innerHTML = `
${!isPro ? 'Pro' : ''}
${!isPro ? 'Pro' : ''}
Manage users, passwords and account-level flags from “Manage users”. Use “Folder Access” for per-folder ACLs. User Groups and Client Portals are available in FileRise Pro. `; if (showSourcesSection) { initSourcesSection({ modalEl: mdl, sourcesEnabled, sourcesCfg, isPro, proSourcesApiOk }); } // Wiring for the 4 buttons const userHubBtn = document.getElementById("adminOpenUserHub"); if (userHubBtn) { userHubBtn.addEventListener("click", () => { openAdminUserHubModal(); }); } const folderAccessBtn = document.getElementById("adminOpenFolderAccess"); if (folderAccessBtn) { folderAccessBtn.addEventListener("click", () => { openUserPermissionsModal(); }); } const groupsBtn = document.getElementById("adminOpenUserGroups"); if (groupsBtn) { groupsBtn.addEventListener("click", () => { if (!isPro) { showToast(t('admin_pro_feature_user_groups')); window.open("https://filerise.net", "_blank", "noopener"); return; } openUserGroupsModal(); }); } const clientBtn = document.getElementById("adminOpenClientPortal"); if (clientBtn) { clientBtn.addEventListener("click", () => { if (!isPro) { showToast(t('admin_pro_feature_portals')); window.open("https://filerise.net", "_blank", "noopener"); return; } openClientPortalsModal(); }); } document.getElementById("headerSettingsContent").innerHTML = `
${t("header_title_text")}
${tf("default_language_help", "Used when a user has not chosen a language yet.")}

${isPro ? 'Upload a logo image or paste a local path.' : 'Requires FileRise Pro to enable custom header branding.'}

${isPro ? 'If left empty, FileRise uses its default blue and dark header colors.' : 'Requires FileRise Pro to enable custom color branding.'}

${tf("hover_preview_max_image_help", "Applies to hover previews and gallery thumbnails. Default 8 MB; higher values increase bandwidth and memory use.")}
${tf("hover_preview_max_video_help", "Applies to hover previews and gallery thumbnails. Default 200 MB; higher values can increase bandwidth on large videos.")}
${ffmpegPathLockedByEnv ? ffmpegHelpLocked : ffmpegHelpDefault}
${tf("file_list_summary_depth_help", "Caps recursive folder totals. 0 = unlimited, 1 = children only, 2 = grandchildren, etc.")}
${tf("indexing_ignore_rules", "Indexing ignore rules")}
${tf("ignore_regex_help", "One pattern per line. Matches entry name or relative path from root (no leading slash; e.g. \"projects/snapshot/2024\").")} ${tf("ignore_regex_scope_note", "Affects folder tree, counts, and indexing. Dot-prefixed entries (like .snapshots) are already hidden; use this for non-dot snapshot folders (snapshot, @snapshots).")} Built-in ignores: dot-prefixed entries (including .snapshots), @eaDir, #recycle, .DS_Store, Thumbs.db, trash, profile_pics.
Quick add:
${ignoreRegexLockedByEnv ? `Env FR_IGNORE_REGEX overrides and locks this field.` : `Env FR_IGNORE_REGEX overrides this field when set.`}

${isPro ? 'Shown at the bottom of every page. You can include simple HTML like links.' : 'Requires FileRise Pro to customize footer text.'}
`; wireHeaderTitleLive(); // Upload logo -> reuse profile picture endpoint, then fill the logo path if (isPro) { const fileInput = document.getElementById('brandingLogoFile'); const uploadBtn = document.getElementById('brandingUploadBtn'); const urlInput = document.getElementById('brandingCustomLogoUrl'); if (fileInput && uploadBtn && urlInput) { uploadBtn.addEventListener('click', async () => { const f = fileInput.files && fileInput.files[0]; if (!f) { showToast(t('admin_logo_choose_image')); return; } const fd = new FormData(); fd.append('brand_logo', f); // <- must match PHP field try { const res = await fetch('/api/pro/uploadBrandLogo.php', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken }, body: fd }); const text = await res.text(); let js = {}; try { js = JSON.parse(text || '{}'); } catch (e) { js = {}; } if (!res.ok || !js.url) { showToast(js.error || t('admin_logo_upload_error')); return; } const normalized = normalizeLogoPath(js.url); // your helper urlInput.value = normalized; showToast(t('admin_logo_uploaded_reminder')); } catch (e) { console.error(e); showToast(t('admin_logo_upload_error')); } }); } } document.getElementById("loginOptionsContent").innerHTML = `
${tf("login_options", "Login options")}
${tf( "proxy_only_login_help", "When enabled, FileRise trusts the reverse proxy header and disables the login form, HTTP Basic and OIDC." )}

WebDAV access
`; // --- Firewall / Proxy Settings (Published URL) --- const deployInfo = (config && typeof config === 'object' && config.deployment && typeof config.deployment === 'object') ? config.deployment : {}; const publishedLocked = !!deployInfo.publishedUrlLockedByEnv; const publishedEffective = (deployInfo.publishedUrlEffective || '').toString(); const publishedCfg = (deployInfo.publishedUrl || '').toString(); const basePathEff = (deployInfo.basePath || '').toString(); const shareUrlEff = (deployInfo.shareUrl || '').toString(); document.getElementById("networkContent").innerHTML = `
${tf("published_server_uris", "Published server URIs")}
${tf( "published_url_help", "Overrides the base URL FileRise uses when generating share links and redirects (useful behind reverse proxies and subpath installs). Leave blank to use auto-detection." )} ${publishedLocked ? ` Controlled by env FR_PUBLISHED_URL. ` : ``}

`; renderAdminEncryptionSection({ config, dark }); document.getElementById("uploadContent").innerHTML = `
${tf("upload_settings", "Upload settings")}
${tf( "resumable_chunk_size_help", "Applies to file picker uploads (Resumable.js). Use smaller chunks if your proxy limits request size (e.g., Cloudflare Tunnels 100 MB)." )}

${tf("antivirus_upload_scanning", "Antivirus upload scanning")}
${tf( "clamav_help_text_short", "Files are scanned with ClamAV before being accepted. This may impact upload speed." )}
${tf( "clamav_exclude_dirs_help", "Comma or newline separated paths relative to the source root (example: snapshot, tmp). For Pro sources you can prefix with source id (example: s3:/snapshot)." )}
${tf( "clamav_test_help", "Runs a quick scan against a tiny test file using your configured ClamAV command (VIRUS_SCAN_CMD or clamscan). Safe to run anytime." )}

${isPro ? `
Virus detection log
Recent uploads that were blocked by ClamAV (username, IP and filename).
Timestamp (UTC) User IP File Folder
No virus detections have been logged yet.
` : `
Virus detection log Pro
Recent uploads that were blocked by ClamAV (username, IP and filename).
Timestamp (UTC) User IP File Folder
 
 
 
 
Pro Virus detection log is a Pro feature
Upgrade to FileRise Pro to view detailed ClamAV detection history and download it as CSV from the admin panel.
` } `; const uploadScope = document.getElementById("uploadContent"); const headerSettingsScope = document.getElementById("headerSettingsContent"); wireIgnoreRegexPresetButton(headerSettingsScope); wireClamavTestButton(uploadScope); initVirusLogUI({ isPro }); // ONLYOFFICE section (moved into adminOnlyOffice.js) initOnlyOfficeUI({ config }); const hasId = !!(config.oidc && config.oidc.hasClientId); const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); const oidcDebugEnabled = !!(config.oidc && config.oidc.debugLogging); const oidcAllowDemote = !!(config.oidc && config.oidc.allowDemote); const oidcPublicClient = !!(config.oidc && config.oidc.publicClient); const oidcHtml = `
OIDC Configuration
Client ID/Secret are never shown after saving. A green note indicates a value is saved. Click “Replace” to overwrite. For OIDC: 1) create an app in your IdP (Authentik, Keycloak, etc), 2) paste its issuer/base URL below, 3) configure the redirect URI in your IdP, 4) then run the test.

Security note: In production, always configure your IdP and FileRise over https://. Plain http:// should only be used for local testing or lab environments.

Use the issuer / base URL from your provider (without the /.well-known/openid-configuration suffix).
Avoid http:// in production – many IdPs and browsers will block insecure OIDC redirects or set cookies incorrectly.
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })} ${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
${tf("oidc_public_client_help", "Uses PKCE (S256) with token auth method \"none\". Leave unchecked for confidential clients that send a client secret.")}
This must exactly match the redirect/callback URL configured in your IdP application.

When enabled, if a user loses admin privileges in your IdP, FileRise will also demote them from admin to regular user on next OIDC login.
When disabled (default), once a user is an admin in FileRise, role changes in the IdP will not demote them automatically.
Container env FR_OIDC_ALLOW_DEMOTE overrides this setting.

This checks that FileRise can reach your provider’s /.well-known/openid-configuration endpoint using the URL above. Save settings first if you changed the URL.


When enabled, FileRise logs extra non-sensitive OIDC info to the PHP error log (issuer, redirect URI, auth method, group counts, etc). Turn this on only while troubleshooting, then disable it.

Generates a redacted JSON snapshot (no secrets) of how FileRise sees your OIDC configuration and environment. Useful to copy/paste into a support ticket.


  

TOTP Configuration
`; const loginOptsHost = document.getElementById("loginOptionsContent"); if (loginOptsHost) { loginOptsHost.insertAdjacentHTML('beforeend', oidcHtml); wireReplaceButtons(loginOptsHost); wireOidcTestButton(loginOptsHost); wireOidcDebugSnapshotButton(loginOptsHost); } const shareLinksHost = document.getElementById("shareLinksContent"); if (shareLinksHost) { shareLinksHost.innerHTML = `
${t("shared_max_upload_size_bytes")}
${t("max_bytes_shared_uploads_note")}

${tf("manage_shared_links", "Manage shared links")}
`; } // --- FileRise Pro / License section --- const proContent = document.getElementById("proContent"); if (proContent) { if (!proPrimaryAdmin) { proContent.innerHTML = `
FileRise Pro
This is only viewable on the registered administrator.
`; } else { // Normalize versions so "v1.0.1" and "1.0.1" compare cleanly const norm = (v) => (String(v || '').trim().replace(/^v/i, '')); const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : ''; const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || ''; const hasCurrent = !!norm(currentVersionRaw); const hasLatest = !!norm(latestVersionRaw); const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw); const hasSavedLicense = !!(proLicense && String(proLicense).trim().startsWith('FRP1.')); const showRenewLinks = isPro || hasSavedLicense; const showInstallOptions = isPro || hasSavedLicense; const showUpdateBadge = hasUpdate && !updatesExpired; const autoUpdateAllowed = hasSavedLicense && !updatesExpired; const autoUpdateBlockedMessage = hasSavedLicense ? 'Updates expired. Renew to download newer Pro bundles.' : 'Save a license key to enable automatic download.'; const updatesExpiredNotice = updatesExpired && proUpdatesUntil ? `
Updates expired on ${escapeHTML(proUpdatesUntil)}. Downloading or installing a newer Pro bundle will deactivate Pro. Renew to update.
` : ''; // Friendly description of plan + lifetime/expiry let planLabel = ''; if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) { const mj = proMaxMajor || 1; planLabel = `Early supporter – lifetime for FileRise Pro ${mj}.x`; } else if (proPlan) { if (proPlan.startsWith('personal_') || proPlan === 'personal_yearly') { planLabel = 'Personal license'; } else if (proPlan.startsWith('business_') || proPlan === 'business_yearly') { planLabel = 'Business license'; } else { planLabel = proPlan; } } let updatesLabel = ''; if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) { const mj = proMaxMajor || 1; updatesLabel = `Updates: lifetime for FileRise Pro ${mj}.x`; } else if (proUpdatesUntil) { updatesLabel = updatesExpired ? `Updates expired on ${proUpdatesUntil} (Pro stays active)` : `Updates until ${proUpdatesUntil}`; } const proMetaHtml = isPro && (proType || proEmail || proVersion || planLabel || updatesLabel) ? `
✅ ${proType ? `License type: ${proType}` : 'License active'} ${proType && proEmail ? ' • ' : ''} ${proEmail ? `Licensed to: ${proEmail}` : ''}
${planLabel ? `
Plan: ${planLabel}
` : ''} ${updatesLabel ? `
${updatesLabel}
` : ''} ${hasCurrent ? `
Installed Pro bundle: v${norm(currentVersionRaw)}
` : ''} ${hasLatest ? `
Latest Pro bundle (UI hint): ${latestVersionRaw}
` : ''}
` : ''; const needsCompatWarning = isPro && proApiLevel > 0 && proApiLevel < CORE_REQUIRED_PRO_API_LEVEL; const compatHtml = needsCompatWarning ? `
Compatibility warning: Core features require Pro API level ${CORE_REQUIRED_PRO_API_LEVEL} (v${PRO_API_MIN_VERSION_LABELS.sources}+). Installed Pro API level: ${proApiLevel}. Those features will stay disabled until you update the Pro bundle.
${updatesExpired ? `
Updates expired on ${escapeHTML(proUpdatesUntil)}. Renew to download newer Pro bundles.
` : ''}
` : ''; const instanceHtml = proInstanceId ? `
Instance ID (required for 12-month updates plans): ${proInstanceId}
` : ''; proContent.innerHTML = `
FileRise Pro ${isPro ? 'Active' : 'Free'}
${isPro ? 'Pro features are currently enabled on this instance.' : 'You are running the free edition. Enter a license key to activate FileRise Pro.'}
${proMetaHtml} ${compatHtml} ${instanceHtml}
${!isPro ? `
Buy FileRise Pro Opens filerise.net in a new tab so you can purchase a FileRise Pro license. You will need your Instance ID during checkout.
` : ''}
${isPro && proLicense ? ` ` : ''}
You can purchase a license at filerise.net.
Supported: FileRise.lic, plain text with FRP1... or JSON containing a license field.
${showRenewLinks ? `
Renewals extend updates for 12-month plans. Instance IDs apply to Business 12-month updates.
` : ''} ${showInstallOptions ? `
Install / update Pro bundle
Choose a method. Manual uploads never contact external services.
${updatesExpiredNotice}
Manual upload
Download the ZIP from filerise.net and upload it here.
One-click download
Uses your saved license key and requires outbound access to filerise.net.
${showUpdateBadge ? ` Update available ` : ''}
` : `
Install / update Pro bundle
Save a license key to unlock manual upload and one-click install options.
`}
`; const updatePill = document.getElementById('proUpdatePill'); if (updatePill && !updatePill.__wired && !updatesExpired) { updatePill.__wired = true; updatePill.addEventListener('click', async (e) => { e.preventDefault(); const choice = await showProUpdateChoiceModal({ hasAuto: autoUpdateAllowed }); if (choice === 'manual') { window.open('https://filerise.net/pro/update.php', '_blank', 'noopener'); return; } if (choice === 'auto') { const dlBtn = document.getElementById('btnDownloadProBundle'); if (dlBtn && !dlBtn.disabled) { dlBtn.click(); } else { showToast(autoUpdateBlockedMessage, 'error'); } } }); } // Wire up local Pro bundle installer (upload .zip into core) initProBundleInstaller({ updatesExpired, updatesUntil: proUpdatesUntil }); // Pre-fill textarea with saved license if present const licenseTextarea = document.getElementById('proLicenseInput'); if (licenseTextarea && proLicense) { licenseTextarea.value = proLicense; } // Auto-load license when a file is selected const fileInput = document.getElementById('proLicenseFile'); if (fileInput && licenseTextarea) { fileInput.addEventListener('change', () => { const file = fileInput.files && fileInput.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { let raw = String(e.target.result || '').trim(); let license = raw; try { const js = JSON.parse(raw); if (js && typeof js.license === 'string') { license = js.license.trim(); } } catch (_) { // not JSON, treat as plain text } if (!license || !license.startsWith('FRP1.')) { showToast(t('admin_license_file_invalid')); return; } licenseTextarea.value = license; showToast(t('admin_license_loaded_prompt')); }; reader.onerror = () => { showToast(t('admin_license_read_error')); }; reader.readAsText(file); }); } // Copy current license button (now inline next to the label) const proCopyBtn = document.getElementById('proCopyLicenseBtn'); if (proCopyBtn && proLicense) { proCopyBtn.addEventListener('click', async () => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(proLicense); } else { const ta = document.createElement('textarea'); ta.value = proLicense; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } showToast(t('admin_license_copied')); } catch (e) { showToast(t('admin_license_copy_failed')); } }); } const proCopyInstanceBtn = document.getElementById('proCopyInstanceIdBtn'); if (proCopyInstanceBtn && proInstanceId) { proCopyInstanceBtn.addEventListener('click', async () => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(proInstanceId); } else { const ta = document.createElement('textarea'); ta.value = proInstanceId; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } showToast(t('admin_instance_id_copied')); } catch (e) { showToast(t('admin_instance_id_copy_failed')); } }); } // Save license handler const proSaveBtn = document.getElementById('proSaveLicenseBtn'); if (proSaveBtn) { proSaveBtn.addEventListener('click', async () => { const ta = document.getElementById('proLicenseInput'); const license = (ta && ta.value.trim()) || ''; const statusEl = document.getElementById('proBundleStatus'); const setStatus = (msg, tone = 'muted') => { if (!statusEl) return; statusEl.textContent = msg || ''; statusEl.className = `small text-${tone}`; }; try { proSaveBtn.disabled = true; const res = await fetch('/api/admin/setLicense.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '') }, body: JSON.stringify({ license }), }); const text = await res.text(); let data = {}; try { data = JSON.parse(text || '{}'); } catch (e) { data = {}; } if (!res.ok || !data.success) { console.error('setLicense error:', res.status, text); showToast(data.error || t('admin_license_save_error')); return; } showToast(t('admin_license_saved')); if (!isPro) { const ok = await showCustomConfirmModal( 'Download and install the latest Pro bundle now?' ); if (ok) { setStatus('Downloading and installing latest Pro bundle...', 'muted'); try { const resp = await fetch('/api/admin/downloadProBundle.php', { method: 'POST', headers: { 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '') }, credentials: 'include' }); let dlData = null; try { dlData = await resp.json(); } catch (_) { dlData = null; } if (!resp.ok || !dlData || !dlData.success) { const msg = dlData && (dlData.error || dlData.message) ? (dlData.error || dlData.message) : `HTTP ${resp.status}`; setStatus('Download/install failed: ' + msg, 'danger'); showToast(t('admin_pro_install_failed_detail', { error: msg }), 'error'); return; } const finalVersion = dlData.proVersion ? String(dlData.proVersion) : ''; const versionText = finalVersion ? ` (${finalVersion})` : ''; setStatus('Pro bundle installed' + versionText + '. Reloading...', 'success'); showToast(t('admin_pro_installed_reloading', { version: versionText })); if (typeof loadAdminConfigFunc === 'function') { loadAdminConfigFunc(); } setTimeout(() => { window.location.reload(); }, 800); return; } catch (e) { setStatus('Download/install failed.', 'danger'); showToast(t('admin_pro_install_failed'), 'error'); return; } } } window.location.reload(); } catch (e) { console.error(e); showToast(t('admin_license_save_error')); } finally { proSaveBtn.disabled = false; } }); } } } // --- end FileRise Pro section --- // Pro features (Search Everywhere + Audit Logs) const proFeaturesContainer = document.getElementById('proFeaturesContent'); const proFeaturesHeaderEl = document.getElementById('proFeaturesHeader'); if (proFeaturesHeaderEl) { const iconHtml = 'expand_more'; const pill = (!isPro || !proSearchApiOk || !proAuditAvailable) ? 'Pro' : ''; const labelHtml = `Pro Features${pill} ${iconHtml}`; const inner = proFeaturesHeaderEl.querySelector('.section-header-inner'); if (inner) { inner.innerHTML = labelHtml; } else { proFeaturesHeaderEl.innerHTML = `
${labelHtml}
`; } } if (proFeaturesContainer) { const proSearchBlockedReason = !isPro ? 'pro' : (!proSearchApiOk ? 'api' : null); const needsUpgradeText = (!isPro) ? 'Requires an active FileRise Pro license.' : (!proSearchApiOk ? `Requires FileRise Pro v${PRO_API_MIN_VERSION_LABELS.search}+.` : ''); const proSearchHtml = `
Search Everywhere
Global, ACL-aware search across all folders.
${proSearchLocked ? `
Locked by FR_PRO_SEARCH_ENABLED env override.
` : ''} ${needsUpgradeText ? `
${needsUpgradeText}
` : ''}
Used when launching Search Everywhere; per-request limit is still capped at 200.
${(!isPro || !proSearchApiOk) ? `
${!isPro ? 'This feature is part of FileRise Pro. Purchase or activate a license to enable it.' : ('Please upgrade your FileRise Pro bundle to v' + PRO_API_MIN_VERSION_LABELS.search + ' or newer to use Search Everywhere.')}
` : ''}
`; const auditBlockedReason = !isPro ? 'pro' : (!proAuditAvailable ? 'upgrade' : null); const auditHelpText = (!isPro) ? 'Requires an active FileRise Pro license.' : (!proAuditAvailable ? `Upgrade FileRise Pro to v${PRO_API_MIN_VERSION_LABELS.audit}+ to enable Audit Logs.` : ''); const auditHtml = `
Audit logging
Who did what, when, and where. Stored in FR_PRO_BUNDLE_DIR/audit/
${auditHelpText ? `
${auditHelpText}
` : ''}
Rotation keeps the newest file plus up to ${proAuditMaxFiles - 1} archives.
Activity history
Filter and export audit events.
User / action / source / storage / folder / dates
Time User Action Source Storage Folder Path From To IP User Agent Meta
`; proFeaturesContainer.innerHTML = proSearchHtml + auditHtml; const proSearchToggle = document.getElementById('proSearchEnabled'); const proSearchLimit = document.getElementById('proSearchLimit'); const syncProSearchLimit = () => { if (!proSearchLimit || !proSearchToggle) return; const locked = proSearchToggle.dataset.locked === '1' || !isPro || !proSearchApiOk; const enabled = !!proSearchToggle.checked; proSearchLimit.disabled = locked || !enabled; }; if (proSearchToggle && !proSearchToggle.__wired) { proSearchToggle.__wired = true; proSearchToggle.addEventListener('change', syncProSearchLimit); } syncProSearchLimit(); const auditEnabledEl = document.getElementById('proAuditEnabled'); const auditLevelEl = document.getElementById('proAuditLevel'); const auditMaxFileMbEl = document.getElementById('proAuditMaxFileMb'); const auditMaxFilesEl = document.getElementById('proAuditMaxFiles'); const syncAuditConfigFields = () => { if (!auditEnabledEl) return; const locked = auditBlockedReason || !isPro || !proAuditAvailable; const enabled = !!auditEnabledEl.checked; if (auditLevelEl) auditLevelEl.disabled = !!locked || !enabled; if (auditMaxFileMbEl) auditMaxFileMbEl.disabled = !!locked || !enabled; if (auditMaxFilesEl) auditMaxFilesEl.disabled = !!locked || !enabled; }; if (auditEnabledEl && !auditEnabledEl.__wired) { auditEnabledEl.__wired = true; auditEnabledEl.addEventListener('change', syncAuditConfigFields); } syncAuditConfigFields(); const auditStatusEl = document.getElementById('auditStatus'); const auditTableBody = document.getElementById('auditTableBody'); const auditFiltersWrap = document.getElementById('auditFiltersWrap'); const auditFiltersToggle = document.getElementById('auditFiltersToggle'); if (auditFiltersWrap && auditFiltersToggle && !auditFiltersToggle.__wired) { auditFiltersToggle.__wired = true; let isOpen = false; try { isOpen = localStorage.getItem('auditFiltersOpen') === '1'; } catch (e) { } const setOpen = (open) => { auditFiltersWrap.style.display = open ? 'flex' : 'none'; auditFiltersToggle.textContent = open ? 'Hide filters' : 'Show filters'; }; setOpen(isOpen); auditFiltersToggle.addEventListener('click', () => { isOpen = !isOpen; try { localStorage.setItem('auditFiltersOpen', isOpen ? '1' : '0'); } catch (e) { } setOpen(isOpen); }); } else if (auditFiltersWrap) { auditFiltersWrap.style.display = 'none'; } const auditFilters = () => { const params = new URLSearchParams(); const add = (k, v) => { if (v) params.set(k, v); }; add('user', (document.getElementById('auditFilterUser')?.value || '').trim()); add('action', (document.getElementById('auditFilterAction')?.value || '').trim()); add('source', (document.getElementById('auditFilterSource')?.value || '').trim()); add('storage', (document.getElementById('auditFilterStorage')?.value || '').trim()); add('folder', (document.getElementById('auditFilterFolder')?.value || '').trim()); add('from', (document.getElementById('auditFilterFrom')?.value || '').trim()); add('to', (document.getElementById('auditFilterTo')?.value || '').trim()); const lim = parseInt((document.getElementById('auditFilterLimit')?.value || '200'), 10); if (lim > 0) params.set('limit', String(Math.min(500, lim))); return params; }; const renderAuditRows = (rows) => { if (!auditTableBody) return; auditTableBody.textContent = ''; if (!rows || !rows.length) { const tr = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = 12; td.className = 'text-muted'; td.textContent = 'No audit entries found for this filter.'; tr.appendChild(td); auditTableBody.appendChild(tr); return; } rows.forEach(row => { const tr = document.createElement('tr'); const storageName = row.storageName || ''; const storageId = row.storageId || ''; let storageLabel = storageName || storageId || ''; if (storageName && storageId && storageName !== storageId) { storageLabel = `${storageName} (${storageId})`; } const cols = [ row.ts || '', row.user || '', row.action || '', row.source || '', storageLabel, row.folder || '', row.path || '', row.from || '', row.to || '', row.ip || '', row.ua || '', row.meta || '' ]; cols.forEach((val, idx) => { const td = document.createElement('td'); let text = val; if (idx === 11 && val && typeof val === 'object') { try { text = JSON.stringify(val); } catch (e) { text = ''; } } if (idx === 10 && typeof text === 'string' && text.length > 30) { td.title = text; text = text.slice(0, 30) + '...'; } if (idx === 11 && typeof text === 'string' && text.length > 160) { td.title = text; text = text.slice(0, 160) + '...'; } td.textContent = (text == null ? '' : String(text)); tr.appendChild(td); }); auditTableBody.appendChild(tr); }); }; const loadAuditLogs = async () => { if (!auditStatusEl) return; if (!isPro || !proAuditAvailable) { auditStatusEl.textContent = auditHelpText || 'Audit Logs are not available.'; return; } auditStatusEl.textContent = 'Loading audit logs...'; if (auditTableBody) auditTableBody.textContent = ''; try { const params = auditFilters(); const url = withBase('/api/pro/audit/list.php?' + params.toString()); const res = await fetch(url, { credentials: 'include' }); const data = await safeJson(res); const rows = data && Array.isArray(data.rows) ? data.rows : []; renderAuditRows(rows); auditStatusEl.textContent = data && data.truncated ? 'Showing latest results (truncated).' : 'Loaded ' + rows.length + ' entries.'; } catch (e) { console.error('Audit log load error', e); auditStatusEl.textContent = (e && e.message) ? e.message : 'Failed to load audit logs.'; renderAuditRows([]); } }; const refreshBtn = document.getElementById('auditRefreshBtn'); if (refreshBtn && !refreshBtn.__wired) { refreshBtn.__wired = true; refreshBtn.addEventListener('click', loadAuditLogs); } const exportBtn = document.getElementById('auditExportBtn'); if (exportBtn && !exportBtn.__wired) { exportBtn.__wired = true; exportBtn.addEventListener('click', () => { if (!isPro || !proAuditAvailable) { showToast(t('admin_audit_logs_unavailable')); return; } const params = auditFilters(); const url = withBase('/api/pro/audit/exportCsv.php?' + params.toString()); window.location.href = url; }); } // Initial load for admins if available if (isPro && proAuditAvailable) { loadAuditLogs(); } // Ensure header toggle works even if the core listener missed it const pfHeader = document.getElementById('proFeaturesHeader'); if (pfHeader && !pfHeader.__wired) { pfHeader.__wired = true; pfHeader.addEventListener('click', () => toggleSection('proFeatures')); } } document.getElementById("saveAdminSettings") .addEventListener("click", handleSave); const loginToggleIds = ["enableFormLogin", "enableBasicAuth", "enableOIDCLogin"]; const ensureAtLeastOneLogin = (changedEl) => { const proxyEl = document.getElementById("authBypass"); const proxyOnly = !!proxyEl && proxyEl.checked; const enabledCount = loginToggleIds .map(id => document.getElementById(id)) .filter(el => el && el.checked).length; // If proxy-only is OFF, we require at least one login method if (!proxyOnly && enabledCount === 0 && changedEl) { showToast(t("at_least_one_login_method")); changedEl.checked = true; } }; loginToggleIds.forEach(id => { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", (e) => { ensureAtLeastOneLogin(e.target); }); }); const authBypassEl = document.getElementById("authBypass"); if (authBypassEl) { authBypassEl.addEventListener("change", (e) => { const checked = e.target.checked; if (checked) { // Proxy-only: switch off all built-in logins loginToggleIds.forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } else { // Leaving proxy-only: if everything is off, enable login form by default const enabledCount = loginToggleIds .map(id => document.getElementById(id)) .filter(el => el && el.checked).length; if (enabledCount === 0) { const fallback = document.getElementById("enableFormLogin"); if (fallback) fallback.checked = true; } } }); } const userMgmt = document.getElementById("userManagementContent"); userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick); window.__userMgmtDelegatedClick = (e) => { const flagsBtn = e.target.closest("#adminOpenUserFlags"); if (flagsBtn) { e.preventDefault(); openUserFlagsModal(); } const folderBtn = e.target.closest("#adminOpenUserPermissions"); if (folderBtn) { e.preventDefault(); openUserPermissionsModal(); } }; userMgmt?.addEventListener("click", window.__userMgmtDelegatedClick); const loginOpts = config.loginOptions || {}; const formEnabled = !(loginOpts.disableFormLogin === true); const basicEnabled = !(loginOpts.disableBasicAuth === true); const oidcEnabled = !(loginOpts.disableOIDCLogin === true); const proxyOnly = !!loginOpts.authBypass; document.getElementById("enableFormLogin").checked = formEnabled; document.getElementById("enableBasicAuth").checked = basicEnabled; document.getElementById("enableOIDCLogin").checked = oidcEnabled; document.getElementById("authBypass").checked = proxyOnly; document.getElementById("authHeaderName").value = loginOpts.authHeaderName || "X-Remote-User"; // If proxy-only is on, force all built-in login toggles off if (proxyOnly) { ["enableFormLogin", "enableBasicAuth", "enableOIDCLogin"].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; const uploadCfg = (config.uploads && typeof config.uploads === "object") ? config.uploads : {}; const chunkEl = document.getElementById("resumableChunkMb"); if (chunkEl) { const raw = uploadCfg.resumableChunkMb; const num = parseFloat(raw); const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5; chunkEl.value = val; } // Published URL (optional) const deploy = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {}; const pubEl = document.getElementById("publishedUrl"); if (pubEl) { pubEl.value = (deploy.publishedUrl || "").toString(); if (deploy.publishedUrlLockedByEnv) { pubEl.disabled = true; pubEl.dataset.locked = "1"; pubEl.value = (deploy.publishedUrlEffective || deploy.publishedUrl || "").toString(); } else { pubEl.disabled = false; pubEl.dataset.locked = "0"; } } // FFmpeg path (optional) const ffmpegEl = document.getElementById("ffmpegPath"); if (ffmpegEl) { const locked = !!config.ffmpegPathLockedByEnv; const val = locked ? (config.ffmpegPathEffective || config.ffmpegPath || "") : (config.ffmpegPath || ""); ffmpegEl.value = (val || "").toString(); ffmpegEl.disabled = locked; ffmpegEl.dataset.locked = locked ? "1" : "0"; const help = document.getElementById("ffmpegPathHelp"); if (help) { help.textContent = locked ? ffmpegHelpLocked : ffmpegHelpDefault; } } // --- ClamAV toggle wiring --- const cfgClam = config.clamav || {}; const clamChk = document.getElementById("clamavScanUploads"); if (clamChk) { clamChk.checked = !!cfgClam.scanUploads; if (cfgClam.lockedByEnv) { // Env var VIRUS_SCAN_ENABLED is controlling this – show as read-only clamChk.disabled = true; const help = document.getElementById("clamavScanUploadsHelp"); if (help) { help.textContent = 'Controlled by container env VIRUS_SCAN_ENABLED (' + (cfgClam.scanUploads ? 'enabled' : 'disabled') + '). Change it in your Docker/host env.'; } } } const clamExclude = document.getElementById("clamavExcludeDirs"); if (clamExclude) { clamExclude.value = (cfgClam.excludeDirs || "").toString(); if (cfgClam.excludeLockedByEnv) { clamExclude.disabled = true; const help = document.getElementById("clamavExcludeDirsHelp"); if (help) { help.textContent = 'Controlled by container env VIRUS_SCAN_EXCLUDE_DIRS. Change it in your Docker/host env.'; } } } // Rebuild ONLYOFFICE section from fresh config initOnlyOfficeUI({ config }); captureInitialAdminConfig(); } else { mdl.style.display = "flex"; const hasId = !!(config.oidc && config.oidc.hasClientId); const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); const loginOpts = config.loginOptions || {}; const formEnabled = !(loginOpts.disableFormLogin === true); const basicEnabled = !(loginOpts.disableBasicAuth === true); const oidcEnabled = !(loginOpts.disableOIDCLogin === true); const proxyOnly = !!loginOpts.authBypass; document.getElementById("enableFormLogin").checked = formEnabled; document.getElementById("enableBasicAuth").checked = basicEnabled; document.getElementById("enableOIDCLogin").checked = oidcEnabled; document.getElementById("authBypass").checked = proxyOnly; document.getElementById("authHeaderName").value = loginOpts.authHeaderName || "X-Remote-User"; if (proxyOnly) { ["enableFormLogin", "enableBasicAuth", "enableOIDCLogin"].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; const uploadCfg2 = (config.uploads && typeof config.uploads === "object") ? config.uploads : {}; const chunkEl2 = document.getElementById("resumableChunkMb"); if (chunkEl2) { const raw = uploadCfg2.resumableChunkMb; const num = parseFloat(raw); const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5; chunkEl2.value = val; } // Published URL (optional) const deploy2 = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {}; const pubEl2 = document.getElementById("publishedUrl"); if (pubEl2) { pubEl2.value = (deploy2.publishedUrl || "").toString(); if (deploy2.publishedUrlLockedByEnv) { pubEl2.disabled = true; pubEl2.dataset.locked = "1"; pubEl2.value = (deploy2.publishedUrlEffective || deploy2.publishedUrl || "").toString(); } else { pubEl2.disabled = false; pubEl2.dataset.locked = "0"; } } // FFmpeg path (optional) const ffmpegEl2 = document.getElementById("ffmpegPath"); if (ffmpegEl2) { const locked = !!config.ffmpegPathLockedByEnv; const val = locked ? (config.ffmpegPathEffective || config.ffmpegPath || "") : (config.ffmpegPath || ""); ffmpegEl2.value = (val || "").toString(); ffmpegEl2.disabled = locked; ffmpegEl2.dataset.locked = locked ? "1" : "0"; const help = document.getElementById("ffmpegPathHelp"); if (help) { help.textContent = locked ? ffmpegHelpLocked : ffmpegHelpDefault; } } const ignoreEl = document.getElementById("ignoreRegex"); if (ignoreEl) { const locked = !!config.ignoreRegexLockedByEnv; const val = locked ? (config.ignoreRegexEffective || "") : (config.ignoreRegex || ""); ignoreEl.value = (val || "").toString(); ignoreEl.disabled = locked; ignoreEl.dataset.locked = locked ? "1" : "0"; } // --- ClamAV toggle wiring (refresh) --- const cfgClam = config.clamav || {}; const clamChk = document.getElementById("clamavScanUploads"); if (clamChk) { clamChk.checked = !!cfgClam.scanUploads; // Reset any previous disabled/help, then re-apply clamChk.disabled = false; const help = document.getElementById("clamavScanUploadsHelp"); if (help) { help.textContent = 'Files are scanned with ClamAV before being accepted. This may impact upload speed.'; } if (cfgClam.lockedByEnv) { clamChk.disabled = true; if (help) { help.textContent = 'Controlled by container env VIRUS_SCAN_ENABLED (' + (cfgClam.scanUploads ? 'enabled' : 'disabled') + '). Change it in your Docker/host env.'; } } } const clamExclude = document.getElementById("clamavExcludeDirs"); if (clamExclude) { clamExclude.value = (cfgClam.excludeDirs || "").toString(); clamExclude.disabled = false; const help = document.getElementById("clamavExcludeDirsHelp"); if (help) { help.textContent = 'Comma or newline separated paths relative to the source root (example: snapshot, tmp). For Pro sources you can prefix with source id (example: s3:/snapshot).'; } if (cfgClam.excludeLockedByEnv) { clamExclude.disabled = true; if (help) { help.textContent = 'Controlled by container env VIRUS_SCAN_EXCLUDE_DIRS. Change it in your Docker/host env.'; } } } const uploadScope = document.getElementById("uploadContent"); const headerSettingsScope = document.getElementById("headerSettingsContent"); wireIgnoreRegexPresetButton(headerSettingsScope); wireClamavTestButton(uploadScope); initVirusLogUI({ isPro }); renderAdminEncryptionSection({ config, dark }); document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || ""; const publicClientChk = document.getElementById("oidcPublicClient"); if (publicClientChk) { publicClientChk.checked = !!window.currentOIDCConfig?.publicClient; } const idEl = document.getElementById("oidcClientId"); const secEl = document.getElementById("oidcClientSecret"); if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || ""; if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || ""; const oidcScope = document.getElementById("oidcContent") || document.getElementById("loginOptionsContent"); if (oidcScope) { wireReplaceButtons(oidcScope); wireOidcTestButton(oidcScope); } document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled); document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : ""; const ooCont = document.getElementById("onlyofficeContent"); if (ooCont) wireReplaceButtons(ooCont); document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || ""; document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || ""; document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || ''; const oidcDebugEl = document.getElementById('oidcDebugLogging'); if (oidcDebugEl) { oidcDebugEl.checked = !!(config.oidc && config.oidc.debugLogging); } const oidcAllowDemoteEl = document.getElementById('oidcAllowDemote'); if (oidcAllowDemoteEl) { oidcAllowDemoteEl.checked = !!(config.oidc && config.oidc.allowDemote); } if (oidcScope) { wireOidcDebugSnapshotButton(oidcScope); } // Refresh Pro features section when reopening const pfHeader = document.getElementById('proFeaturesHeader'); if (pfHeader) { const iconHtml = 'expand_more'; const pill = (!isPro || !proSearchApiOk || !proAuditAvailable) ? 'Pro' : ''; const labelHtml = `Pro Features${pill} ${iconHtml}`; const inner = pfHeader.querySelector('.section-header-inner'); if (inner) { inner.innerHTML = labelHtml; } else { pfHeader.innerHTML = `
${labelHtml}
`; } } const psToggle = document.getElementById("proSearchEnabled"); const psLimit = document.getElementById("proSearchLimit"); if (psToggle) { psToggle.checked = proSearchEnabled; if (proSearchLocked) { psToggle.dataset.locked = "1"; } else { psToggle.removeAttribute("data-locked"); } } if (psLimit) { psLimit.value = proSearchDefaultLimit; } const syncPs = () => { if (!psToggle || !psLimit) return; const locked = psToggle.dataset.locked === "1" || !isPro || !proSearchApiOk; psLimit.disabled = locked || !psToggle.checked; }; if (psToggle && !psToggle.__wired) { psToggle.__wired = true; psToggle.addEventListener("change", syncPs); } syncPs(); captureInitialAdminConfig(); } try { initAdminStorageSection({ isPro, modalEl: mdl }); } catch (e) { console.error('Failed to init Storage / Disk Usage section', e); } try { initAdminSponsorSection({ container: document.getElementById('sponsorContent'), t, tf, showToast }); } catch (e) { console.error('Failed to init Thanks / Sponsor / Donations section', e); } }) .catch(() => {/* if even fetching fails, open empty panel */ }); } function handleSave() { const enableFormLogin = !!document.getElementById("enableFormLogin")?.checked; const enableBasicAuth = !!document.getElementById("enableBasicAuth")?.checked; const enableOIDCLogin = !!document.getElementById("enableOIDCLogin")?.checked; const proxyOnlyEnabled = !!document.getElementById("authBypass")?.checked; const oidcPublicClient = !!document.getElementById("oidcPublicClient")?.checked; const authHeaderName = (document.getElementById("authHeaderName")?.value || "").trim() || "X-Remote-User"; const payload = { header_title: document.getElementById("headerTitle")?.value || "", publishedUrl: (() => { const el = document.getElementById("publishedUrl"); if (!el) return ""; if (el.dataset.locked === "1") return el.value || ""; return (el.value || "").trim(); })(), ffmpegPath: (() => { const el = document.getElementById("ffmpegPath"); if (!el) return ""; if (el.dataset.locked === "1") return el.value || ""; return (el.value || "").trim(); })(), ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(), loginOptions: { // Backend still expects “disable*” flags: disableFormLogin: !enableFormLogin, disableBasicAuth: !enableBasicAuth, disableOIDCLogin: !enableOIDCLogin, authBypass: proxyOnlyEnabled, authHeaderName, }, 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(), debugLogging: !!document.getElementById("oidcDebugLogging")?.checked, allowDemote: !!document.getElementById("oidcAllowDemote")?.checked, publicClient: oidcPublicClient, // clientId/clientSecret added conditionally below }, globalOtpauthUrl: document .getElementById("globalOtpauthUrl") .value.trim(), branding: { customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(), headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(), headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(), footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(), }, display: { defaultLanguage: (document.getElementById("defaultLanguage")?.value || "en").trim(), hoverPreviewMaxImageMb: Math.max( 1, Math.min( 50, parseInt(document.getElementById("hoverPreviewMaxImageMb")?.value || "8", 10) || 8 ) ), hoverPreviewMaxVideoMb: Math.max( 1, Math.min( 2048, parseInt(document.getElementById("hoverPreviewMaxVideoMb")?.value || "200", 10) || 200 ) ), fileListSummaryDepth: Math.max( 0, Math.min( 10, Number.isFinite(parseInt(document.getElementById("fileListSummaryDepth")?.value || "2", 10)) ? parseInt(document.getElementById("fileListSummaryDepth")?.value || "2", 10) : 2 ) ), }, uploads: { resumableChunkMb: Math.max( 0.5, Math.min( 100, parseFloat(document.getElementById("resumableChunkMb")?.value || "1.5") || 1.5 ) ), }, clamav: { scanUploads: document.getElementById("clamavScanUploads").checked, excludeDirs: (document.getElementById("clamavExcludeDirs")?.value || "").trim(), }, proSearch: { enabled: !!document.getElementById("proSearchEnabled")?.checked, defaultLimit: Math.max( 1, Math.min( 200, parseInt(document.getElementById("proSearchLimit")?.value || "50", 10) || 50 ) ), }, proAudit: { enabled: !!document.getElementById("proAuditEnabled")?.checked, level: (document.getElementById("proAuditLevel")?.value || "verbose").trim(), maxFileMb: Math.max( 10, parseInt(document.getElementById("proAuditMaxFileMb")?.value || "200", 10) || 200 ), maxFiles: Math.max( 1, Math.min( 10, parseInt(document.getElementById("proAuditMaxFiles")?.value || "10", 10) || 10 ) ), }, }; // --- OIDC extras (unchanged) --- const idEl = document.getElementById("oidcClientId"); const scEl = document.getElementById("oidcClientSecret"); const idVal = idEl?.value.trim() || ''; const secVal = scEl?.value.trim() || ''; const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') { payload.oidc.clientId = idVal; } if (oidcPublicClient) { // Explicitly clear any stored secret when switching to public client mode payload.oidc.clientSecret = ''; } else if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') { payload.oidc.clientSecret = secVal; } // ONLYOFFICE settings (moved into adminOnlyOffice.js) collectOnlyOfficeSettingsForSave(payload); // --- save call (unchanged) --- fetch('/api/admin/updateConfig.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '') }, body: JSON.stringify(payload) }) .then(r => r.json()) .then(j => { if (j.error) { showToast(t('error_prefix', { error: j.error })); return; } showToast(t('admin_settings_saved')); closeAdminPanel(); applyHeaderColorsFromAdmin(); updateHeaderLogoFromAdmin(); applyFooterFromAdmin(); }) .catch(() => showToast(t('admin_settings_save_failed'))); } export async function closeAdminPanel() { if (hasUnsavedChanges()) { //const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); //if (!ok) return; } const m = document.getElementById("adminPanelModal"); if (m) m.style.display = "none"; } async function fetchAllUserFlags() { const r = await fetch("/api/getUserPermissions.php", { credentials: "include" }); const data = await r.json(); if (data && typeof data === "object") { const map = data.allPermissions || data.permissions || data; if (map && typeof map === "object") { Object.values(map).forEach(u => { if (u && typeof u === "object") delete u.folderOnly; }); } } if (Array.isArray(data)) { const out = {}; data.forEach(u => { if (u.username) out[u.username] = u; }); return out; } if (data && data.allPermissions) return data.allPermissions; if (data && data.permissions) return data.permissions; return data || {}; } function flagRow(u, flags) { const f = flags[u.username] || {}; const isAdmin = isAdminUser(u); const disabledAttr = isAdmin ? "disabled data-admin='1' title='Admin: full access'" : ""; const note = isAdmin ? " (Admin)" : ""; return ` ${u.username}${note} `; } export async function openUserFlagsModal() { const isDark = document.body.classList.contains("dark-mode"); const overlayBg = isDark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const contentBg = isDark ? "#2c2c2c" : "#fff"; const contentFg = isDark ? "#e0e0e0" : "#000"; const borderCol = isDark ? "#555" : "#ccc"; let modal = document.getElementById("userFlagsModal"); if (!modal) { modal = document.createElement("div"); modal.id = "userFlagsModal"; modal.style.cssText = ` position:fixed; inset:0; background:${overlayBg}; display:flex; align-items:center; justify-content:center; z-index:3600; `; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById("closeUserFlagsModal").onclick = () => (modal.style.display = "none"); document.getElementById("cancelUserFlags").onclick = () => (modal.style.display = "none"); document.getElementById("saveUserFlags").onclick = saveUserFlags; } else { modal.style.background = overlayBg; const content = modal.querySelector(".modal-content"); if (content) { content.style.background = contentBg; content.style.color = contentFg; content.style.border = `1px solid ${borderCol}`; } } modal.style.display = "flex"; loadUserFlagsList(); } async function loadUserFlagsList() { const body = document.getElementById("userFlagsBody"); if (!body) return; body.textContent = `${t("loading")}…`; try { const users = await fetchAllUsers(); const flagsMap = await fetchAllUserFlags(); const rows = users.map(u => flagRow(u, flagsMap)).filter(Boolean).join(""); body.innerHTML = ` ${rows || ``}
${t("user")} ${t("read_only")} ${t("disable_upload")} ${t("can_share")} ${t("bypass_ownership")}
${t("no_users_found")}
`; } catch (e) { console.error(e); body.innerHTML = `
${t("error_loading_users")}
`; } } async function saveUserFlags() { const body = document.getElementById("userFlagsBody"); const rows = body?.querySelectorAll("tbody tr[data-username]") || []; const permissions = []; rows.forEach(tr => { if (tr.getAttribute("data-admin") === "1") return; // don't send admin updates const username = tr.getAttribute("data-username"); const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked; permissions.push({ username, readOnly: get("readOnly"), disableUpload: get("disableUpload"), canShare: get("canShare"), bypassOwnership: get("bypassOwnership") }); }); try { const res = await sendRequest("/api/updateUserPermissions.php", "PUT", { permissions }, { "X-CSRF-Token": window.csrfToken } ); if (res && res.success) { showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); const m = document.getElementById("userFlagsModal"); if (m) m.style.display = "none"; } else { showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } } catch (e) { console.error(e); showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } } async function loadUserPermissionsList() { const listContainer = document.getElementById("userPermissionsList"); if (!listContainer) return; listContainer.innerHTML = `

${t("loading")}…

`; try { // Load users + groups together (folders separately) const [usersRes, groupsMap] = await Promise.all([ fetch("/api/getUsers.php", { credentials: "include" }).then(safeJson), fetchAllGroups().catch(() => ({})) ]); const users = Array.isArray(usersRes) ? usersRes : (usersRes.users || []); const groups = groupsMap && typeof groupsMap === "object" ? groupsMap : {}; if (!users.length && !Object.keys(groups).length) { listContainer.innerHTML = "

" + t("no_users_found") + "

"; return; } // Keep cache in sync with the groups UI __groupsCache = groups || {}; const folders = await getAllFolders(true); const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; // Build map: username -> [groupName, ...] const userGroupMap = {}; Object.keys(groups).forEach(gName => { const g = groups[gName] || {}; const members = Array.isArray(g.members) ? g.members : []; members.forEach(m => { const u = String(m || "").trim(); if (!u) return; if (!userGroupMap[u]) userGroupMap[u] = []; userGroupMap[u].push(gName); }); }); // Clear the container and render sections listContainer.innerHTML = ""; // ==================== // Groups section (top) // ==================== const groupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b)); if (groupNames.length) { const groupHeader = document.createElement("div"); groupHeader.className = "muted"; groupHeader.style.margin = "4px 0 6px"; groupHeader.textContent = tf("groups_header", "Groups"); listContainer.appendChild(groupHeader); groupNames.forEach(name => { const g = groups[name] || {}; const label = g.label || name; const members = Array.isArray(g.members) ? g.members : []; const membersSummary = members.length ? members.join(", ") : tf("no_members", "No members yet"); const row = document.createElement("div"); row.classList.add("user-permission-row", "group-permission-row"); row.setAttribute("data-group-name", name); row.style.padding = "6px 0"; row.innerHTML = ` `; // Safely inject dynamic text: const labelEl = row.querySelector('.group-label'); if (labelEl) { labelEl.textContent = label; // no HTML, just text } const membersEl = row.querySelector('.members-summary'); if (membersEl) { membersEl.textContent = `${tf("members_label", "Members")}: ${membersSummary}`; } 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"); // Load this group's folder ACL (from __groupsCache) and show it read-only async function ensureLoaded() { if (grantsBox.dataset.loaded === "1") return; try { const group = __groupsCache[name] || {}; const grants = group.grants || {}; renderFolderGrantsUI( name, grantsBox, orderedFolders, grants ); // Make it clear: edit in User groups → Edit folder access grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.disabled = true; cb.title = tf( "edit_group_acl_in_user_groups", "Group ACL is read-only here. Use User groups → Edit folder access to change it." ); }); grantsBox.dataset.loaded = "1"; } catch (e) { console.error(e); grantsBox.innerHTML = `
${tf("error_loading_group_grants", "Error loading group grants")}
`; } } 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(); } }); listContainer.appendChild(row); }); // divider between groups and users const hr = document.createElement("hr"); hr.style.margin = "6px 0 10px"; hr.style.border = "0"; hr.style.borderTop = "1px solid rgba(0,0,0,0.08)"; listContainer.appendChild(hr); } // ================= // Users section // ================= const sortedUsers = users.slice().sort((a, b) => { const ua = String(a.username || "").toLowerCase(); const ub = String(b.username || "").toLowerCase(); return ua.localeCompare(ub); }); sortedUsers.forEach(user => { const username = String(user.username || "").trim(); const isAdmin = isAdminUser(user); const groupsForUser = userGroupMap[username] || []; const groupBadges = groupsForUser.length ? (() => { const labels = groupsForUser.map(gName => { const g = groups[gName] || {}; return g.label || gName; }); return `${tf("member_of_groups", "Groups")}: ${labels.join(", ")}`; })() : ""; const row = document.createElement("div"); row.classList.add("user-permission-row"); row.setAttribute("data-username", username); if (isAdmin) row.setAttribute("data-admin", "1"); row.style.padding = "6px 0"; row.innerHTML = ` `; 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; const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; if (isAdmin) { // synthesize full access grants = buildFullGrantsForAllFolders(orderedFolders); renderFolderGrantsUI(user.username, grantsBox, orderedFolders, grants); grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true); } else { const userGrants = await getUserGrants(user.username); renderFolderGrantsUI(user.username, grantsBox, orderedFolders, userGrants); // NEW: overlay group-based grants so you can't uncheck them here const groupMask = computeGroupGrantMaskForUser(user.username, orderedFolders); // If you already build a userGroupMap somewhere, you can pass the exact groups; // otherwise we can recompute the list of group names from __groupsCache: const groupsForUser = []; if (__groupsCache && typeof __groupsCache === "object") { Object.keys(__groupsCache).forEach(gName => { const g = __groupsCache[gName] || {}; const members = Array.isArray(g.members) ? g.members : []; if (members.some(m => String(m || "").trim().toLowerCase() === String(user.username || "").trim().toLowerCase())) { groupsForUser.push(gName); } }); } applyGroupLocksForUser(user.username, grantsBox, groupMask, groupsForUser); } grantsBox.dataset.loaded = "1"; } catch (e) { console.error(e); grantsBox.innerHTML = `
${tf("error_loading_user_grants", "Error loading user grants")}
`; } } 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(); } }); listContainer.appendChild(row); }); } catch (err) { console.error(err); listContainer.innerHTML = "

" + t("error_loading_users") + "

"; } }