// 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
? `Replace `
: '';
const note = hasValue
? `Saved — leave blank to keep `
: '';
return `
`;
}
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 = `
enhanced_encryption
${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'}
${tf("generate_key_file", "Generate key file")}
${tf("clear_key_file", "Clear key file")}
${lockedByEnv ? `
${tf("locked_by_env", "Locked by FR_ENCRYPTION_MASTER_KEY env override.")}
` : ''}
${tf("encryption_v1_note", "Admin notes:
Master key can be set via FR_ENCRYPTION_MASTER_KEY (env overrides the key file) or via META_DIR/encryption_master.key (32 raw bytes). Encrypted folders are recursive; shares, shared-folder uploads, WebDAV, and archive create/extract are blocked under encrypted folders. Video/audio previews are disabled (no HTTP Range) but users can still download files normally. ")}
`;
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 = `
Timestamp (UTC)
User
IP
File
Folder
`;
normalized.forEach(entry => {
html += `
${escapeCell(entry.ts)}
${escapeCell(entry.user)}
${escapeCell(entry.ip)}
${escapeCell(entry.file)}
${escapeCell(entry.folder)}
`;
});
html += `
`;
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('source_add', 'Add Source'))}
${escapeHTML(tf('refresh', 'Refresh'))}
${escapeHTML(tf('source_name', 'Source Name'))}
${escapeHTML(tf('source_type', 'Type'))}
${escapeHTML(tf('status', 'Status'))}
Local local ${escapeHTML(tf('enabled', 'Enabled'))}
Archive s3 ${escapeHTML(tf('disabled', 'Disabled'))}
Media smb ${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('source_add', 'Add Source')}
${tf('refresh', 'Refresh')}
`;
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 = `
`;
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
? `lock `
: '';
const testLabel = tf('source_test', 'Test');
return `
${esc(name)} ${lockIcon}${badgeWrap}
${esc(type)}
${esc(id)}
${esc(statusText)}
science
${tf('source_edit', 'Edit')}
${tf('source_delete', 'Delete')}
`;
}).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 = `
Download + install
Manual download
Cancel
`;
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 = `
${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 = `
×
${tf("manage_users", "Manage users")}
${tf(
"manage_users_help",
"Select a user from the list to change their password, delete them, or update account-level flags. Use Add User to create a brand new account."
)}
${t("username")}
person_add
${t("add_user")}
${tf("create_new_user_title", "Create New User")}
${tf(
"create_user_help",
"New users are created immediately and appear in the dropdown at the top."
)}
person_remove
${t("remove_user")}
${tf("refresh", "Refresh")}
${tf(
"user_actions_help_inline",
"Delete, change password, and flags apply to the selected user in the dropdown above."
)}
${tf("user_permissions", "User Permissions")}
${tf(
"user_flags_inline_help_long",
"Account-level switches (read-only, disable upload, can share, bypass ownership) for the selected user. For per-folder ACLs, use Folder Access."
)}
`;
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")} `;
Object.entries(folders).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
const tokenValue = o.token || token;
const sourceLabel = o.sourceName || o.sourceId || "";
const sourceHtml = sourceLabel && sourceLabel.toLowerCase() !== "local"
? ` [${esc(sourceLabel)}] `
: "";
const folderLabel = esc(o.folder || "root");
html += `
${lock}${folderLabel} ${sourceHtml}
(${new Date(o.expires * 1000).toLocaleString()})
🗑️
`;
});
html += ` ${t("file_shares")} `;
Object.entries(files).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
const tokenValue = o.token || token;
const sourceLabel = o.sourceName || o.sourceId || "";
const sourceHtml = sourceLabel && sourceLabel.toLowerCase() !== "local"
? ` [${esc(sourceLabel)}] `
: "";
const folderLabel = esc(o.folder || "root");
const fileLabel = esc(o.file || "");
const pathLabel = fileLabel ? `${folderLabel}/${fileLabel}` : folderLabel;
html += `
${lock}${pathLabel} ${sourceHtml}
(${new Date(o.expires * 1000).toLocaleString()})
🗑️
`;
});
html += ` `;
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 `${label} `;
}).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 = `
people
${tf("manage_users", "Manage users")}
folder_shared
${tf("folder_access", "Folder Access")}
groups
User Groups
${!isPro ? 'Pro ' : ''}
cloud_upload
Client Portals
${!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 = `
${tf("indexing_ignore_rules", "Indexing ignore rules")}
`;
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")}
Auth header name:
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_label", "Published URL (optional)")}
${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.
` : ``}
${tf("effective_base_path", "Effective base path")}
${tf("effective_share_url", "Effective share URL")}
${tf("effective_published_url", "Effective published URL")}
`;
renderAdminEncryptionSection({ config, dark });
document.getElementById("uploadContent").innerHTML = `
${tf("upload_settings", "Upload settings")}
${tf("resumable_chunk_size_label", "Resumable chunk size (MB)")}
${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_exclude_dirs_label", "Exclude upload paths")}
${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_button", "Run ClamAV self-test")}
${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
? `
Timestamp (UTC)
User
IP
File
Folder
No virus detections have been logged yet.
`
: `
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.
${t("oidc_provider_url")}:
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 })}
${t("oidc_redirect_uri")}:
This must exactly match the redirect/callback URL configured in your IdP application.
TOTP Configuration
${t("global_otpauth_url")}:
`;
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 = `
${tf("manage_shared_links", "Manage shared links")}
${t("loading")}…
`;
}
// --- 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)
? `
`
: '';
const needsCompatWarning = isPro
&& proApiLevel > 0
&& proApiLevel < CORE_REQUIRED_PRO_API_LEVEL;
const compatHtml = needsCompatWarning
? `
`
: '';
const instanceHtml = 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.
` : ''}
Or upload license file
Supported: FileRise.lic, plain text with FRP1... or JSON containing a license field.
Save license
${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.
Install Pro bundle
One-click download
Uses your saved license key and requires outbound access to filerise.net.
Download + install latest
${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 = ``;
}
}
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 = `
travel_explore
Search Everywhere
Global, ACL-aware search across all folders.
Default result limit (max 200)
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 = `
fact_check
Audit logging
Who did what, when, and where. Stored in FR_PRO_BUNDLE_DIR/audit/
Logging level
Standard (uploads, edits, renames, deletes)
Verbose (includes downloads)
Rotation keeps the newest file plus up to ${proAuditMaxFiles - 1} archives.
`;
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 = ``;
}
}
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 = `
×
${tf("user_permissions", "User Permissions")}
${tf("user_flags_help", "Non Admin User Account-level switches. These are NOT per-folder grants.")}
${t("loading")}…
${t("cancel")}
${t("save_permissions")}
`;
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 = `
${t("user")}
${t("read_only")}
${t("disable_upload")}
${t("can_share")}
${t("bypass_ownership")}
${rows || `${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") + "
";
}
}