release(v2.5.0): add optional ClamAV upload, share upload & portal upload scanning and Pro virus log

This commit is contained in:
Ryan
2025-12-08 02:10:22 -05:00
committed by GitHub
parent ec5b8b2aeb
commit f92bf55194
15 changed files with 2533 additions and 1050 deletions
+34
View File
@@ -1,5 +1,39 @@
# Changelog
## Changes 12/8/2025 (v2.5.0)
release(v2.5.0): add optional ClamAV upload, share upload & portal upload scanning and Pro virus log
- Wire optional ClamAV scanning into core uploads and shared uploads:
- Respect VIRUS_SCAN_ENABLED env / constant as a hard override
- Fall back to admin config clamav.scanUploads when env is unset
- Block infected uploads with a friendly error and delete the file
- Treat ClamAV errors (missing DB, bad config, etc.) as non-blocking
- Add virus detection JSONL log in META_DIR/virus_detections.log:
- Log timestamp, user, IP, folder, file, source, engine, exit code and message
- Soft-rotate the log at ~5MB
- Add Pro-only virus detection log viewer in Admin → Upload:
- Paginated JSON view with hover/click details and CSV export
- Blurred teaser + Pro badge when FileRise Pro is not active
- Extend the Admin > Upload section:
- New “Upload limits & antivirus” section title
- ClamAV upload scanning toggle with env-locked hint
- “Run ClamAV self-test” button hitting /api/admin/clamavTest.php
- Improve upload UX when antivirus is enabled:
- Show a small non-blocking “Scanning uploads for viruses…” notice while uploads run
- Surface server-side JSON error messages (e.g. “Upload blocked: virus detected in file.”) in the toast
- Docker / startup:
- Install clamav and clamav-freshclam in the Docker image
- Log VIRUS_SCAN_ENABLED and VIRUS_SCAN_CMD on container start
- Optionally run freshclam on startup (CLAMAV_AUTO_UPDATE=true by default), but do not fail the container if it errors
---
## Changes 12/7/2025 (v2.4.0)
release(v2.4.0): OIDC auto-provisioning, admin mapping & Pro group sync
+5 -3
View File
@@ -36,9 +36,11 @@ ENV DEBIAN_FRONTEND=noninteractive \
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
ca-certificates curl git openssl && \
apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
apache2 \
php php-json php-curl php-zip php-mbstring php-gd php-xml \
ca-certificates curl git openssl \
clamav clamav-freshclam \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Remap www-data to the PUID/PGID provided for safe bind mounts
RUN set -eux; \
+20 -9
View File
@@ -23,6 +23,7 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
- 📊 **Storage / disk usage summary** CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
- 🎨 **Polished UI** Dark/light mode, responsive layout, in-browser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.) with optional auto-provisioning, IdP-admin role sync, and Pro user-group mapping.
- 🛡️ **ClamAV virus scanning (Core) + Pro virus log** Optional integration with ClamAV to scan uploads, plus a Pro virus detection log in the admin panel with CSV export.
- 👥 **Pro: user groups, client portals & storage explorer** Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
@@ -151,15 +152,25 @@ docker compose up -d
### Common environment variables
| Variable | Required | Example | What it does |
|-------------------------|----------|----------------------------------|-------------------------------------------------------------------------------|
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). |
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / reverse proxy, else `false`. |
| `PERSISTENT_TOKENS_KEY` | ✅ | `default_please_change_this_key` | Secret used to sign “remember me” tokens. **Change this.** |
| `SCAN_ON_START` | Optional | `true` | If `true`, scan `uploads/` on startup and index existing files. |
| `CHOWN_ON_START` | Optional | `true` | If `true`, chown `uploads/`, `users/`, `metadata/` on startup. |
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates are shown). |
| Variable | Required | Example | What it does |
|-------------------------|----------|----------------------------------|--------------------------------------------------------------------------------------------------------|
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). Also used to set PHP `upload_max_filesize` and `post_max_size`, and Apache `LimitRequestBody`. |
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / a reverse proxy, else `false`. |
| `PERSISTENT_TOKENS_KEY` | ✅ | `change_me_super_secret` | Secret used to sign “remember me”/persistent tokens. **Do not leave this at the default.** |
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates/times are rendered in the UI). |
| `SCAN_ON_START` | Optional | `true` | If `true`, runs `scan_uploads.php` once on container start to index existing files. |
| `CHOWN_ON_START` | Optional | `true` | If `true` (default), recursively `chown`s `uploads/`, `users/`, and `metadata/` to `www-data:www-data` on startup. Set to `false` if you manage ownership yourself. |
| `PUID` | Optional | `99` | If running as root, remap `www-data` user to this UID (e.g. Unraids 99). |
| `PGID` | Optional | `100` | If running as root, remap `www-data` group to this GID (e.g. Unraids 100). |
| `HTTP_PORT` | Optional | `8080` | Override Apache `Listen 80` and vhost port with this port inside the container. |
| `HTTPS_PORT` | Optional | `8443` | If you terminate TLS inside the container, override `Listen 443` with this port. |
| `SERVER_NAME` | Optional | `files.example.com` | Sets Apaches `ServerName` (defaults to `FileRise` if not provided). |
| `LOG_STREAM` | Optional | `error` | Controls which logs are streamed to container stdout: `error`, `access`, `both`, or `none`. |
| `VIRUS_SCAN_ENABLED` | Optional | `true` | If `true`, enable ClamAV-based virus scanning for uploads. |
| `VIRUS_SCAN_CMD` | Optional | `clamscan` | Command used to scan files. Can be `clamscan`, `clamdscan`, or a wrapper with flags. |
| `CLAMAV_AUTO_UPDATE` | Optional | `true` | If `true` and running as root, call `freshclam` on startup to update signatures. |
| `SHARE_URL` | Optional | `https://files.example.com` | Overrides the base URL used when generating public share links (useful behind reverse proxies). |
> If `DATE_TIME_FORMAT` is not set, FileRise uses the default from `config/config.php`
> (currently `m/d/y h:iA`).
+16
View File
@@ -64,6 +64,22 @@ if (!defined('FR_OIDC_PRO_GROUP_PREFIX')) {
define('FR_OIDC_PRO_GROUP_PREFIX', '');
}
// Antivirus / ClamAV (optional)
// If VIRUS_SCAN_ENABLED is set in the environment, it overrides the admin setting.
// If it is not set, we don't define the constant and the admin checkbox controls scanning.
$envScanRaw = getenv('VIRUS_SCAN_ENABLED');
if ($envScanRaw !== false && $envScanRaw !== '') {
$val = strtolower(trim((string)$envScanRaw));
$enabled = in_array($val, ['1', 'true', 'yes', 'on'], true);
define('VIRUS_SCAN_ENABLED', $enabled);
}
// Which scanner command to run. Can be "clamscan" or "clamdscan" (faster with clamd).
define('VIRUS_SCAN_CMD', getenv('VIRUS_SCAN_CMD') ?: 'clamscan');
// Optional: max time you consider acceptable for a scan (for logging / future timeout logic)
define('VIRUS_SCAN_TIMEOUT', 60);
// Encryption helpers
function encryptData($data, $encryptionKey)
{
+8
View File
@@ -0,0 +1,8 @@
<?php
// public/api/admin/clamavTest.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
AdminController::clamavTest();
+8
View File
@@ -0,0 +1,8 @@
<?php
// public/api/admin/virusLog.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
AdminController::virusLog();
+670 -16
View File
@@ -1,13 +1,193 @@
// adminPanel.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { showToast, toggleVisibility, attachEnterKeyListener, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.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}}';
// 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 = `
<td>${escapeHTML(row.ts || '')}</td>
<td>${escapeHTML(row.user || '')}</td>
<td>${escapeHTML(row.ip || '')}</td>
<td>${escapeHTML(row.file || '')}</td>
<td>${escapeHTML(row.folder || '')}</td>
`;
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('Failed to download CSV.', '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(':');
@@ -314,6 +494,250 @@ if (userinfoEndpoint) parts.push('userinfo: ' + userinfoEndpoint);
});
}
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('ClamAV self-test succeeded.');
} 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('ClamAV test failed see console.', 'error');
}
});
}
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 = `
<table class="table table-sm mb-1"
style="width:100%; filter: blur(2px); opacity:0.65; pointer-events:none;">
<thead>
<tr>
<th style="white-space:nowrap;">Timestamp (UTC)</th>
<th>User</th>
<th>IP</th>
<th>File</th>
<th>Folder</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-muted small">
Virus detections from the last 30 days would appear here.
</td>
</tr>
</tbody>
</table>
`;
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 = `
<div style="
background:rgba(0,0,0,0.78);
color:#fff;
padding:8px 14px;
border-radius:999px;
display:flex;
align-items:center;
gap:8px;
font-size:0.85rem;
">
<span class="badge badge-pill badge-warning">Pro</span>
<span>Virus detection log is available in FileRise Pro.</span>
<a href="https://filerise.net"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-light"
style="pointer-events:auto;">
Learn more
</a>
</div>
`;
wrapper.appendChild(overlay);
return;
}
// --- Pro: load real data from /api/admin/virusLog.php ---
shell.innerHTML = `<div class="small text-muted">Loading virus detection log…</div>`;
(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 = `<div class="text-danger small">${msg}</div>`;
return;
}
const rows = Array.isArray(data.rows || data.entries || data.data)
? (data.rows || data.entries || data.data)
: [];
if (!rows.length) {
shell.innerHTML = `<div class="small text-muted">No virus detections have been logged yet.</div>`;
return;
}
const escapeCell = (v) => {
if (v === null || v === undefined) return '';
return String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
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 = `
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="thead-light">
<tr>
<th style="white-space:nowrap;">Timestamp (UTC)</th>
<th>User</th>
<th>IP</th>
<th>File</th>
<th>Folder</th>
</tr>
</thead>
<tbody>
`;
normalized.forEach(entry => {
html += `
<tr>
<td style="white-space:nowrap;">${escapeCell(entry.ts)}</td>
<td>${escapeCell(entry.user)}</td>
<td>${escapeCell(entry.ip)}</td>
<td>${escapeCell(entry.file)}</td>
<td>${escapeCell(entry.folder)}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
shell.innerHTML = html;
} catch (e) {
console.error('virusLog error', e);
shell.innerHTML = `<div class="text-danger small">Error loading detection log. See console for details.</div>`;
}
})();
}
function onShareFolderToggle(row, checked) {
const manage = qs(row, 'input[data-cap="manage"]');
const viewAll = qs(row, 'input[data-cap="view"]');
@@ -386,6 +810,8 @@ function captureInitialAdminConfig() {
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked,
};
}
function hasUnsavedChanges() {
@@ -407,7 +833,9 @@ function hasUnsavedChanges() {
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ||
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "")
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "") ||
getChk("clamavScanUploads") !== o.clamavScanUploads
);
}
@@ -648,12 +1076,20 @@ export function openAdminPanel() {
if (h) h.textContent = config.header_title;
window.headerTitle = config.header_title;
}
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
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 proType = proInfo.type || '';
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
@@ -703,7 +1139,7 @@ export function openAdminPanel() {
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "upload", label: tf("upload_limits_and_antivirus", "Upload limits & antivirus") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") },
{ id: "storage", label: "Storage / Disk Usage" },
@@ -728,11 +1164,23 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "storage", "pro", "sponsor"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
});
[
"userManagement",
"headerSettings",
"loginOptions",
"webdav",
"onlyoffice",
"upload",
"oidc",
"shareLinks",
"storage",
"pro",
"sponsor"
].forEach(id => {
const headerEl = document.getElementById(id + "Header");
if (!headerEl) return;
headerEl.addEventListener("click", () => toggleSection(id));
});
document.getElementById("userManagementContent").innerHTML = `
<div class="admin-user-actions">
@@ -1039,13 +1487,171 @@ export function openAdminPanel() {
`;
document.getElementById("uploadContent").innerHTML = `
<div class="form-group">
<label for="sharedMaxUploadSize">${t("shared_max_upload_size_bytes")}:</label>
<input type="number" id="sharedMaxUploadSize" class="form-control" placeholder="e.g. 52428800" />
<small>${t("max_bytes_shared_uploads_note")}</small>
</div>
`;
<div class="form-group">
<label for="sharedMaxUploadSize">${t("shared_max_upload_size_bytes")}:</label>
<input
type="number"
id="sharedMaxUploadSize"
class="form-control"
placeholder="e.g. 52428800"
/>
<small class="text-muted d-block">
${t("max_bytes_shared_uploads_note")}
</small>
</div>
<div class="form-group" style="margin-top:10px;">
<input type="checkbox" id="clamavScanUploads" />
<label for="clamavScanUploads" style="margin-left:4px;">
${tf("clamav_enable_label", "Enable ClamAV scanning for uploads")}
</label>
<small
id="clamavScanUploadsHelp"
class="d-block text-muted"
style="margin-top:2px;"
>
${tf(
"clamav_help_text_short",
"Files are scanned with ClamAV before being accepted. This may impact upload speed."
)}
</small>
</div>
<div class="mt-2">
<button
type="button"
id="clamavTestBtn"
class="btn btn-sm btn-secondary">
${tf("clamav_test_button", "Run ClamAV self-test")}
</button>
<small class="text-muted d-block" style="margin-top:4px;">
${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."
)}
</small>
<div id="clamavTestStatus" class="small text-muted" style="margin-top:4px;"></div>
</div>
<hr class="mt-3 mb-2">
${
isPro
? `
<!-- Real Pro virus log -->
<div id="virusLogWrapper"
class="card"
style="border-radius: var(--menu-radius); overflow:hidden;">
<div class="card-header py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Virus detection log</strong>
<div class="small text-muted">
Recent uploads that were blocked by ClamAV (username, IP and filename).
</div>
</div>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-sm btn-secondary"
id="virusLogRefreshBtn">
${tf("refresh", "Refresh")}
</button>
<button
type="button"
class="btn btn-sm btn-warning"
id="virusLogDownloadCsvBtn">
${tf("download_csv", "Download CSV")}
</button>
</div>
</div>
</div>
<div class="card-body p-2">
<div class="table-responsive"
style="max-height:220px; overflow:auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th style="width:26%;">Timestamp (UTC)</th>
<th style="width:18%;">User</th>
<th style="width:18%;">IP</th>
<th style="width:24%;">File</th>
<th style="width:14%;">Folder</th>
</tr>
</thead>
<tbody id="virusLogTableBody"></tbody>
</table>
</div>
<div id="virusLogEmpty" class="small text-muted mt-1">
No virus detections have been logged yet.
</div>
</div>
</div>
`
: `
<!-- Pro-style blurred teaser, like Storage explorer -->
<div id="virusLogWrapper"
class="card"
style="border-radius: var(--menu-radius); overflow:hidden; position:relative;">
<div class="card-header py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>
Virus detection log
<span class="badge bg-warning text-dark ms-1 align-middle">Pro</span>
</strong>
<div class="small text-muted">
Recent uploads that were blocked by ClamAV (username, IP and filename).
</div>
</div>
</div>
</div>
<div class="card-body p-2">
<!-- Blurred fake table teaser -->
<div class="table-responsive"
style="max-height:220px;overflow:hidden;filter:blur(3px);opacity:0.5;pointer-events:none;">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Timestamp (UTC)</th>
<th>User</th>
<th>IP</th>
<th>File</th>
<th>Folder</th>
</tr>
</thead>
<tbody>
<tr><td colspan="5">&nbsp;</td></tr>
<tr><td colspan="5">&nbsp;</td></tr>
<tr><td colspan="5">&nbsp;</td></tr>
<tr><td colspan="5">&nbsp;</td></tr>
</tbody>
</table>
</div>
<!-- Centered overlay copy -->
<div
class="d-flex flex-column align-items-center justify-content-center text-center"
style="position:absolute; inset:0; padding:16px;">
<div class="mb-1">
<span class="badge bg-warning text-dark me-1">Pro</span>
<span class="fw-semibold">
Virus detection log is a Pro feature
</span>
</div>
<div class="small text-muted mb-2">
Upgrade to FileRise Pro to view detailed ClamAV detection history
and download it as CSV from the admin panel.
</div>
</div>
</div>
</div>
`
}
`;
wireClamavTestButton(document.getElementById("uploadContent"));
initVirusLogUI({ isPro });
// ONLYOFFICE section (moved into adminOnlyOffice.js)
initOnlyOfficeUI({ config });
@@ -1458,6 +2064,24 @@ export function openAdminPanel() {
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// --- 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.';
}
}
}
// Rebuild ONLYOFFICE section from fresh config
initOnlyOfficeUI({ config });
@@ -1475,6 +2099,32 @@ export function openAdminPanel() {
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// --- 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.';
}
}
}
wireClamavTestButton(document.getElementById("uploadContent"));
initVirusLogUI({ isPro });
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
const idEl = document.getElementById("oidcClientId");
const secEl = document.getElementById("oidcClientSecret");
@@ -1538,6 +2188,10 @@ function handleSave() {
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
},
clamav: {
scanUploads: document.getElementById("clamavScanUploads").checked,
},
};
// --- OIDC extras (unchanged) ---
+8 -1
View File
@@ -365,7 +365,14 @@ const translations = {
"perm_move": "Move",
"perm_rename": "Rename",
"perm_share": "Share",
"perm_delete": "Delete"
"perm_delete": "Delete",
"clamav_scanning_title": "Scanning uploads for viruses…",
"clamav_scanning_desc":"Uploads may take a little longer while antivirus scanning is enabled.",
"oidc_quick_test_label": "Quick OIDC connectivity test",
"oidc_test_button": "Test OIDC discovery",
"clamav_enable_label": "Enable ClamAV scanning for uploads",
"clamav_scan_uploads_help": "Files are scanned with ClamAV before being accepted. This may impact upload speed.",
"clamav_env_locked_help": "Controlled by container env VIRUS_SCAN_ENABLED ({state}). Change it in your Docker/host env."
},
es: {
+9
View File
@@ -448,6 +448,15 @@ function bindDarkMode() {
// ---------- site config / auth ----------
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
// Make config available globally
window.siteConfig = cfg || {};
window.__FR_FLAGS = window.__FR_FLAGS || {};
// Expose a simple boolean for ClamAV scanning
if (cfg && cfg.clamav && typeof cfg.clamav.scanUploads !== 'undefined') {
window.__FR_FLAGS.clamavScanUploads = !!cfg.clamav.scanUploads;
}
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
+114 -1
View File
@@ -6,6 +6,104 @@ import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
// --- ClamAV scanning UI helpers ----------------------------------------
function isVirusScanLikelyEnabled() {
try {
if (
window.__FR_FLAGS &&
Object.prototype.hasOwnProperty.call(window.__FR_FLAGS, 'clamavScanUploads')
) {
return !!window.__FR_FLAGS.clamavScanUploads;
}
// Fallbacks if you ever expose config globals directly
const cfg =
(window.appConfig) ||
(window.FR_CONFIG) ||
(window.__FR_CONFIG__) ||
(window.siteConfig) ||
null;
return !!(cfg && cfg.clamav && cfg.clamav.scanUploads);
} catch {
return false;
}
}
let _virusScanNoticeDismissed = false;
function showVirusScanNotice() {
if (!isVirusScanLikelyEnabled()) return;
if (_virusScanNoticeDismissed) return;
// If already visible, don't duplicate
let existing = document.getElementById('frVirusScanNotice');
if (existing) return;
const box = document.createElement('div');
box.id = 'frVirusScanNotice';
box.className = 'fr-virus-notice card';
// Minimal inline layout so we don't rely on extra CSS
Object.assign(box.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '420px',
width: 'calc(100% - 32px)', // nice on mobile too
zIndex: '1080',
padding: '16px 18px',
borderRadius: '10px',
boxShadow: '0 4px 24px rgba(0,0,0,0.35)',
backgroundColor: getComputedStyle(document.body).backgroundColor || '#fff',
color: getComputedStyle(document.body).color || '#111',
});
box.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div style="display:flex;align-items:center;gap:6px;flex:1;">
<span class="material-icons" style="font-size:20px;flex-shrink:0;">shield</span>
<div style="font-size:0.9rem;">
<div style="font-weight:600;margin-bottom:2px;">
${escapeHTML(t ? t('clamav_scanning_title') || 'Scanning uploads for viruses…' : 'Scanning uploads for viruses…')}
</div>
<div style="font-size:0.8rem;opacity:0.8;">
${escapeHTML(t ? t('clamav_scanning_desc') || 'Uploads may take a little longer while antivirus scanning is enabled.' : 'Uploads may take a little longer while antivirus scanning is enabled.')}
</div>
</div>
</div>
<button type="button"
id="frVirusScanNoticeClose"
class="btn btn-sm btn-outline-secondary"
style="flex-shrink:0;">
${escapeHTML(t ? t('close') || 'Close' : 'Close')}
</button>
</div>
<div class="progress" style="height:6px;margin-top:8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%;"></div>
</div>
`;
document.body.appendChild(box);
const closeBtn = box.querySelector('#frVirusScanNoticeClose');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
_virusScanNoticeDismissed = true; // don't nag again this session
hideVirusScanNotice();
});
}
}
function hideVirusScanNotice() {
const el = document.getElementById('frVirusScanNotice');
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
}
// --- Lightweight tracking of in-progress resumable uploads (per user) ---
const RESUMABLE_DRAFTS_KEY = 'filr_resumable_drafts_v1';
@@ -911,7 +1009,16 @@ async function initResumableUpload() {
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">replay</span>';
pauseResumeBtn.disabled = false;
}
showToast("Error uploading file: " + file.fileName);
let msgText = "Error uploading file: " + (file.fileName || file.name || "");
try {
const parsed = JSON.parse(message);
if (parsed && parsed.error) {
msgText = parsed.error; // e.g. "Upload blocked: virus detected in file."
}
} catch {
// message wasn't JSON, ignore
}
showToast(msgText);
// Treat errored file as no longer resumable (for now) and clear its hint
showResumableDraftBanner();
});
@@ -950,6 +1057,8 @@ async function initResumableUpload() {
} else {
showToast("Some files failed to upload. Please check the list.");
}
// In all cases, once Resumable has finished its batch, hide the ClamAV notice.
hideVirusScanNotice();
});
_resumableReady = true;
@@ -1206,7 +1315,9 @@ function submitFiles(allFiles) {
showToast("Some files may have failed to upload. Please check the list.");
})
.finally(() => {
// Folder list refresh + hide any ClamAV scan notice
loadFolderTree(window.currentFolder);
hideVirusScanNotice();
});
}
}
@@ -1293,6 +1404,8 @@ function initUpload() {
}
setUploadButtonVisible(false);
// If ClamAV scanning is enabled, show a small non-blocking notice
showVirusScanNotice();
const hasResumableFiles =
useResumable &&
File diff suppressed because it is too large Load Diff
+30 -9
View File
@@ -7,6 +7,7 @@ require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderMeta.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class FolderController
{
@@ -32,7 +33,8 @@ class FolderController
return $headers;
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array {
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
{
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
}
@@ -50,7 +52,7 @@ class FolderController
$perms = self::loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly');
$folderOnly = self::boolFrom($perms, 'folderOnly', 'userFolderOnly', 'UserFolderOnly');
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
@@ -107,11 +109,14 @@ class FolderController
$canShareFold = false;
} else {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
&& !$readOnly;
}
$owner = null;
try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {}
try {
if (class_exists('FolderModel') && method_exists('FolderModel', 'getOwnerFor')) $owner = FolderModel::getOwnerFor($folder);
} catch (\Throwable $e) {
}
return [
'user' => $username,
@@ -150,7 +155,8 @@ class FolderController
/* ---------------------------
Private helpers (caps)
----------------------------*/
private static function loadPermsFor(string $u): array {
private static function loadPermsFor(string $u): array
{
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
@@ -164,16 +170,19 @@ class FolderController
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) {}
} catch (\Throwable $e) {
}
return [];
}
private static function boolFrom(array $a, string ...$keys): bool {
private static function boolFrom(array $a, string ...$keys): bool
{
foreach ($keys as $k) if (!empty($a[$k])) return true;
return false;
}
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool
{
$f = ACL::normalizeFolder($folder);
if (ACL::isOwner($user, $perms, $f)) return true;
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
@@ -186,7 +195,8 @@ class FolderController
return false;
}
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool {
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool
{
if ($isAdmin) return true;
if (!$folderOnly) return true; // normal users: global scope
@@ -1211,6 +1221,17 @@ class FolderController
exit;
}
// ---- ClamAV: reuse UploadModel scan logic on the tmp file ----
$scan = UploadModel::scanSingleUploadIfEnabled($fileUpload);
if (is_array($scan) && isset($scan['error'])) {
// scanFileIfEnabled() already deletes the tmp file on infection
http_response_code(400);
header('Content-Type: application/json');
echo json_encode($scan); // e.g. ["error" => "Upload blocked: virus detected in file."]
exit;
}
// --------------------------------------------------------------
$result = FolderModel::uploadToSharedFolder($token, $fileUpload);
if (isset($result['error'])) {
http_response_code(400);
+210 -140
View File
@@ -25,28 +25,44 @@ class AdminModel
$unit = strtolower($m[2] ?? '');
switch ($unit) {
case 'k': case 'kb': case 'kib':
case 'k':
case 'kb':
case 'kib':
$num *= 1024;
break;
case 'm': case 'mb': case 'mib':
case 'm':
case 'mb':
case 'mib':
$num *= 1024 ** 2;
break;
case 'g': case 'gb': case 'gib':
case 'g':
case 'gb':
case 'gib':
$num *= 1024 ** 3;
break;
case 't': case 'tb': case 'tib':
case 't':
case 'tb':
case 'tib':
$num *= 1024 ** 4;
break;
case 'p': case 'pb': case 'pib':
case 'p':
case 'pb':
case 'pib':
$num *= 1024 ** 5;
break;
case 'e': case 'eb': case 'eib':
case 'e':
case 'eb':
case 'eib':
$num *= 1024 ** 6;
break;
case 'z': case 'zb': case 'zib':
case 'z':
case 'zb':
case 'zib':
$num *= 1024 ** 7;
break;
case 'y': case 'yb': case 'yib':
case 'y':
case 'yb':
case 'yib':
$num *= 1024 ** 8;
break;
// case 'b' or empty => bytes; do nothing
@@ -74,79 +90,105 @@ class AdminModel
}
/** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */
private static function sanitizeLogoUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
private static function sanitizeLogoUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
if ($url[0] === '/') {
// Strip CRLF just in case
$url = preg_replace('~[\r\n]+~', '', $url);
// Dont allow sneaky schemes embedded in a relative path
if (strpos($url, '://') !== false) {
return '';
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
if ($url[0] === '/') {
// Strip CRLF just in case
$url = preg_replace('~[\r\n]+~', '', $url);
// Dont allow sneaky schemes embedded in a relative path
if (strpos($url, '://') !== false) {
return '';
}
return $url;
}
return $url;
}
// 2) Fallback to plain http(s) validation
return self::sanitizeHttpUrl($url);
}
// 2) Fallback to plain http(s) validation
return self::sanitizeHttpUrl($url);
}
public static function buildPublicSubset(array $config): array
{
$public = [
'header_title' => $config['header_title'] ?? 'FileRise',
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
],
'branding' => [
'customLogoUrl' => self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
),
'headerBgLight' => self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
),
'headerBgDark' => self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
),
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
],
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
];
{
$public = [
'header_title' => $config['header_title'] ?? 'FileRise',
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
],
'branding' => [
'customLogoUrl' => self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
),
'headerBgLight' => self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
),
'headerBgDark' => self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
),
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
],
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
];
// NEW: include ONLYOFFICE minimal public flag
$ooEnabled = null;
if (isset($config['onlyoffice']['enabled'])) {
$ooEnabled = (bool)$config['onlyoffice']['enabled'];
} elseif (defined('ONLYOFFICE_ENABLED')) {
$ooEnabled = (bool)ONLYOFFICE_ENABLED;
}
if ($ooEnabled !== null) {
$public['onlyoffice'] = ['enabled' => $ooEnabled];
}
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
// --- ONLYOFFICE public flag ---
$ooEnabled = null;
if (isset($config['onlyoffice']['enabled'])) {
$ooEnabled = (bool)$config['onlyoffice']['enabled'];
} elseif (defined('ONLYOFFICE_ENABLED')) {
$ooEnabled = (bool)ONLYOFFICE_ENABLED;
}
if ($locked) {
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
} else {
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
}
$locked = defined('ONLYOFFICE_ENABLED')
|| defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|| defined('ONLYOFFICE_PUBLIC_ORIGIN');
if ($locked) {
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
} else {
$ooEnabled = isset($config['onlyoffice']['enabled'])
? (bool)$config['onlyoffice']['enabled']
: false;
}
$public['onlyoffice'] = ['enabled' => $ooEnabled];
// Keep explicit demoMode override (no harm)
$public['demoMode'] = defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false;
// ClamAV, mirroring AdminController::getConfig() logic ---
$envScanRaw = getenv('VIRUS_SCAN_ENABLED');
if ($envScanRaw !== false && $envScanRaw !== '') {
// Env var wins
$clamScanUploads = filter_var($envScanRaw, FILTER_VALIDATE_BOOLEAN);
$clamLockedByEnv = true;
} elseif (defined('VIRUS_SCAN_ENABLED')) {
// Optional PHP constant override
$clamScanUploads = (bool) VIRUS_SCAN_ENABLED;
$clamLockedByEnv = true;
} else {
// Fall back to stored admin config
$clamScanUploads = (bool)($config['clamav']['scanUploads'] ?? false);
$clamLockedByEnv = false;
}
$public['clamav'] = [
'scanUploads' => $clamScanUploads,
'lockedByEnv' => $clamLockedByEnv,
];
return $public;
}
@@ -194,7 +236,7 @@ private static function sanitizeLogoUrl($url): string
if (!$oidcDisabled) {
$oidc = $configUpdate['oidc'] ?? [];
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
$required = ['providerUrl', 'clientId', 'clientSecret', 'redirectUri'];
foreach ($required as $k) {
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
@@ -208,20 +250,36 @@ private static function sanitizeLogoUrl($url): string
: false;
// Validate sharedMaxUploadSize if provided
if (isset($configUpdate['sharedMaxUploadSize'])) {
$sms = filter_var(
$configUpdate['sharedMaxUploadSize'],
FILTER_VALIDATE_INT,
["options" => ["min_range" => 1]]
);
if ($sms === false) {
return ["error" => "Invalid sharedMaxUploadSize."];
if (array_key_exists('sharedMaxUploadSize', $configUpdate)) {
$raw = $configUpdate['sharedMaxUploadSize'];
// If blank or zero, treat as "no override" and drop the key
if ($raw === '' || $raw === null || (int)$raw <= 0) {
unset($configUpdate['sharedMaxUploadSize']);
} else {
$sms = filter_var(
$raw,
FILTER_VALIDATE_INT,
["options" => ["min_range" => 1]]
);
if ($sms === false) {
return ["error" => "Invalid sharedMaxUploadSize."];
}
$totalBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
if ($sms > $totalBytes) {
return ["error" => "sharedMaxUploadSize must be ≤ TOTAL_UPLOAD_SIZE."];
}
$configUpdate['sharedMaxUploadSize'] = $sms;
}
$totalBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
if ($sms > $totalBytes) {
return ["error" => "sharedMaxUploadSize must be ≤ TOTAL_UPLOAD_SIZE."];
}
$configUpdate['sharedMaxUploadSize'] = $sms;
}
// ---- ClamAV (simple boolean flag) ----
if (!isset($configUpdate['clamav']) || !is_array($configUpdate['clamav'])) {
$configUpdate['clamav'] = [
'scanUploads' => false,
];
} else {
$configUpdate['clamav']['scanUploads'] = !empty($configUpdate['clamav']['scanUploads']);
}
// Normalize authBypass & authHeaderName
@@ -240,53 +298,53 @@ private static function sanitizeLogoUrl($url): string
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
}
// ---- ONLYOFFICE (persist, sanitize; keep secret unless explicitly replaced) ----
if (isset($configUpdate['onlyoffice']) && is_array($configUpdate['onlyoffice'])) {
$oo = $configUpdate['onlyoffice'];
$norm = [
'enabled' => (bool)($oo['enabled'] ?? false),
'docsOrigin' => self::sanitizeHttpUrl($oo['docsOrigin'] ?? ''),
'publicOrigin' => self::sanitizeHttpUrl($oo['publicOrigin'] ?? ''),
];
// Only accept a new secret if provided (non-empty). We do NOT clear on empty.
if (array_key_exists('jwtSecret', $oo)) {
$js = trim((string)$oo['jwtSecret']);
if ($js !== '') {
if (strlen($js) > 1024) $js = substr($js, 0, 1024);
$norm['jwtSecret'] = $js; // will be encrypted with encryptData()
}
}
$configUpdate['onlyoffice'] = $norm;
}
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
$configUpdate['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
];
} else {
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
$configUpdate['branding']['customLogoUrl'] = $logo;
$configUpdate['branding']['headerBgLight'] = $light;
$configUpdate['branding']['headerBgDark'] = $dark;
$configUpdate['branding']['footerHtml'] = $footer;
} else {
$configUpdate['branding']['customLogoUrl'] = '';
$configUpdate['branding']['headerBgLight'] = '';
$configUpdate['branding']['headerBgDark'] = '';
$configUpdate['branding']['footerHtml'] = '';
}
// ---- ONLYOFFICE (persist, sanitize; keep secret unless explicitly replaced) ----
if (isset($configUpdate['onlyoffice']) && is_array($configUpdate['onlyoffice'])) {
$oo = $configUpdate['onlyoffice'];
$norm = [
'enabled' => (bool)($oo['enabled'] ?? false),
'docsOrigin' => self::sanitizeHttpUrl($oo['docsOrigin'] ?? ''),
'publicOrigin' => self::sanitizeHttpUrl($oo['publicOrigin'] ?? ''),
];
// Only accept a new secret if provided (non-empty). We do NOT clear on empty.
if (array_key_exists('jwtSecret', $oo)) {
$js = trim((string)$oo['jwtSecret']);
if ($js !== '') {
if (strlen($js) > 1024) $js = substr($js, 0, 1024);
$norm['jwtSecret'] = $js; // will be encrypted with encryptData()
}
}
$configUpdate['onlyoffice'] = $norm;
}
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
$configUpdate['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
];
} else {
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
$configUpdate['branding']['customLogoUrl'] = $logo;
$configUpdate['branding']['headerBgLight'] = $light;
$configUpdate['branding']['headerBgDark'] = $dark;
$configUpdate['branding']['footerHtml'] = $footer;
} else {
$configUpdate['branding']['customLogoUrl'] = '';
$configUpdate['branding']['headerBgLight'] = '';
$configUpdate['branding']['headerBgDark'] = '';
$configUpdate['branding']['footerHtml'] = '';
}
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
@@ -329,16 +387,16 @@ private static function sanitizeLogoUrl($url): string
}
private static function sanitizeColorHex($value): string
{
$value = trim((string)$value);
if ($value === '') return '';
{
$value = trim((string)$value);
if ($value === '') return '';
// allow #RGB or #RRGGBB
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
return strtoupper($value);
// allow #RGB or #RRGGBB
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
return strtoupper($value);
}
return '';
}
return '';
}
/**
* Retrieves the current configuration.
@@ -388,7 +446,7 @@ private static function sanitizeLogoUrl($url): string
'redirectUri' => '',
];
} else {
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $k) {
foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri'] as $k) {
if (!isset($config['oidc'][$k]) || !is_string($config['oidc'][$k])) {
$config['oidc'][$k] = '';
}
@@ -461,6 +519,15 @@ private static function sanitizeLogoUrl($url): string
);
}
// ---- ClamAV: ensure structure exists ----
if (!isset($config['clamav']) || !is_array($config['clamav'])) {
$config['clamav'] = [
'scanUploads' => false,
];
} else {
$config['clamav']['scanUploads'] = !empty($config['clamav']['scanUploads']);
}
return $config;
}
@@ -492,6 +559,9 @@ private static function sanitizeLogoUrl($url): string
'headerBgDark' => '',
'footerHtml' => '',
],
'clamav' => [
'scanUploads' => false,
],
];
}
}
}
+292 -1
View File
@@ -2,9 +2,16 @@
// src/models/UploadModel.php
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
class UploadModel
{
/**
* Log file for virus detections (JSONL; one JSON record per line).
*/
private const VIRUS_LOG_FILE = META_DIR . 'virus_detections.log';
private const VIRUS_LOG_MAX_BYTES = 5242880; // 5 MB soft rotation
private static function sanitizeFolder(string $folder): string
{
// decode "%20", normalise slashes & trim via ACL helper
@@ -31,6 +38,75 @@ class UploadModel
return $f; // safe, normalised, with spaces allowed
}
private static function isVirusScanEnabled(): bool
{
// 1) Container env override (most explicit)
$env = getenv('VIRUS_SCAN_ENABLED');
if ($env !== false && $env !== '') {
// Accept "1", "true", "0", "false", etc.
$envBool = filter_var($env, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return $envBool === true;
}
// 2) PHP constant override (non-container / legacy setups)
if (defined('VIRUS_SCAN_ENABLED')) {
return (bool)VIRUS_SCAN_ENABLED;
}
// 3) Admin configuration toggle
if (!class_exists('AdminModel')) {
return false;
}
$cfg = AdminModel::getConfig();
if (!is_array($cfg) || isset($cfg['error'])) {
return false;
}
if (empty($cfg['clamav']) || !is_array($cfg['clamav'])) {
return false;
}
return !empty($cfg['clamav']['scanUploads']);
}
/**
* Public helper: scan a single $_FILES-style upload array, if ClamAV is enabled.
*
* Used by places like shared-folder upload so they can reuse the same logic.
*
* $context may include:
* - 'user' => override username (default: current session)
* - 'ip' => override client IP (default: derived from $_SERVER)
* - 'folder' => logical folder name / path
* - 'file' => original file name
* - 'source' => e.g. "normal", "shared", "portal", "self_test"
* - 'suppressLog'=> true to skip logging (used by self-test endpoint)
*
* Returns:
* - null => scanning disabled or clean
* - ['error' => ] => infected or scan error (file is deleted)
*/
public static function scanSingleUploadIfEnabled(array $upload, array $context = []): ?array
{
// Respect same toggle logic (env + admin config)
if (!self::isVirusScanEnabled()) {
return null;
}
$tmp = $upload['tmp_name'] ?? '';
if (!$tmp || !is_file($tmp)) {
return ['error' => 'Virus scan failed: uploaded file not found.'];
}
// Default file name in log context, if not provided by caller
if (!isset($context['file']) && isset($upload['name'])) {
$context['file'] = (string)$upload['name'];
}
return self::scanFileIfEnabled($tmp, $context);
}
public static function handleUpload(array $post, array $files): array
{
// --- GET resumable test (make folder handling consistent) ---
@@ -125,6 +201,20 @@ class UploadModel
}
fclose($out);
// Optional: virus scan the merged file
$folderForLog = ($folderSan === '' ? 'root' : $folderSan);
$scanResult = self::scanFileIfEnabled($targetPath, [
'folder' => $folderForLog,
'file' => $resumableFilename,
'source' => 'normal', // core/resumable upload
]);
if (is_array($scanResult) && isset($scanResult['error'])) {
// Clean up temporary chunk directory
self::rrmdir($tempDir);
return $scanResult; // e.g. "Upload blocked: virus detected in file."
}
// Metadata
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
@@ -203,6 +293,36 @@ class UploadModel
return ['error' => 'Error uploading file'];
}
// Compute logical folder for logging: relative to UPLOAD_DIR
$folderForLog = 'root';
$rootDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (strpos($targetPath, $rootDir) === 0) {
$rel = substr($targetPath, strlen($rootDir));
$rel = str_replace(DIRECTORY_SEPARATOR, '/', $rel);
$slashPos = strrpos($rel, '/');
if ($slashPos !== false) {
$folderRel = substr($rel, 0, $slashPos);
if ($folderRel !== '') {
$folderForLog = $folderRel;
}
}
} elseif ($folderSan !== '') {
// Fallback: if above fails, use sanitized folder
$folderForLog = $folderSan;
}
// Optional: virus scan this file
$scanResult = self::scanFileIfEnabled($targetPath, [
'folder' => $folderForLog,
'file' => $safeFileName,
'source' => 'normal', // core non-resumable upload
]);
if (is_array($scanResult) && isset($scanResult['error'])) {
// scanFileIfEnabled already unlinks the file on failure/infection
return $scanResult;
}
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
@@ -239,6 +359,61 @@ class UploadModel
return ['success' => 'Files uploaded successfully'];
}
/**
* Optionally scan an uploaded file with ClamAV.
*
* $context may include the same keys as scanSingleUploadIfEnabled().
*
* Returns:
* - null => scanning disabled or file clean
* - ['error' => ] => infected or scan error (file is deleted)
*/
private static function scanFileIfEnabled(string $path, array $context = []): ?array
{
// Respect env override + admin setting
if (!self::isVirusScanEnabled()) {
return null; // scanning disabled
}
if (!is_file($path)) {
return ['error' => 'Virus scan failed: uploaded file not found.'];
}
$cmd = defined('VIRUS_SCAN_CMD') ? VIRUS_SCAN_CMD : 'clamscan';
$cmdline = escapeshellcmd($cmd)
. ' --stdout --no-summary '
. escapeshellarg($path)
. ' 2>&1';
$output = [];
$exitCode = 0;
@exec($cmdline, $output, $exitCode);
$msg = trim(implode("\n", $output));
// 0 = clean
if ($exitCode === 0) {
return null;
}
// 1 = virus found → block + delete + log
if ($exitCode === 1) {
// Allow self-test endpoints to suppress log if they pass suppressLog=true
if (empty($context['suppressLog'])) {
self::logVirusDetection($path, $msg, $context, $cmd, $exitCode);
}
@unlink($path);
return [
'error' => 'Upload blocked: virus detected in file.',
];
}
// >1 = scanner error (missing DB, bad config, etc.)
// Log but do NOT block the upload.
error_log("ClamAV scan error (exit={$exitCode}, cmd={$cmd}): {$msg}");
return null;
}
/**
* Recursively removes a directory and its contents.
*
@@ -294,4 +469,120 @@ class UploadModel
return ['error' => 'Failed to remove temporary folder.'];
}
}
/**
* Append a virus detection record to META_DIR/virus_detections.log (JSONL).
*
* @param string $path The scanned file path on disk.
* @param string $rawMessage Raw clamscan output (stdout/stderr combined).
* @param array $context Extra context: folder, file, user, ip, source, etc.
* @param string $cmd Command used (clamscan / custom).
* @param int $exitCode ClamAV exit code.
*/
private static function logVirusDetection(
string $path,
string $rawMessage,
array $context,
string $cmd,
int $exitCode
): void {
try {
if (!is_dir(META_DIR)) {
@mkdir(META_DIR, 0775, true);
}
$user = $context['user'] ?? ($_SESSION['username'] ?? 'Unknown');
$ip = $context['ip'] ?? self::getClientIp();
$source = $context['source'] ?? 'normal';
// Folder + file in log prefer context, fallback to path
$fileName = $context['file'] ?? basename($path);
$folder = $context['folder'] ?? null;
if ($folder === null) {
// Best-effort: derive folder relative to UPLOAD_DIR
$rootDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (strpos($path, $rootDir) === 0) {
$rel = substr($path, strlen($rootDir));
$rel = str_replace(DIRECTORY_SEPARATOR, '/', $rel);
$pos = strrpos($rel, '/');
$folder = ($pos !== false) ? substr($rel, 0, $pos) : '';
} else {
$folder = '';
}
}
$msg = self::truncateLogMessage($rawMessage, 400);
$record = [
'ts' => gmdate('c'),
'user' => $user,
'ip' => $ip,
'folder' => ($folder === '' ? 'root' : $folder),
'file' => $fileName,
'source' => $source,
'engine' => $cmd,
'exitCode' => $exitCode,
'message' => $msg,
];
$json = json_encode($record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return;
}
// *** Canonical base path: matches virusLog.php ***
$baseMeta = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
$logFile = $baseMeta . 'virus_detections.log';
// Soft rotation
if (file_exists($logFile) && filesize($logFile) > self::VIRUS_LOG_MAX_BYTES) {
$ts = date('Ymd-His');
$rot = $baseMeta . 'virus_detections-' . $ts . '.log';
@rename($logFile, $rot);
}
@file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX);
} catch (\Throwable $e) {
// Never break uploads because logging failed.
error_log('Failed to log virus detection: ' . $e->getMessage());
}
}
/**
* Best-effort client IP resolution for logging.
*/
private static function getClientIp(): string
{
$keys = [
'HTTP_X_FORWARDED_FOR',
'HTTP_CLIENT_IP',
'REMOTE_ADDR',
];
foreach ($keys as $key) {
if (!empty($_SERVER[$key])) {
$val = trim((string)$_SERVER[$key]);
// X-Forwarded-For may contain multiple IPs use first
if ($key === 'HTTP_X_FORWARDED_FOR' && strpos($val, ',') !== false) {
$parts = explode(',', $val);
$val = trim($parts[0]);
}
return $val;
}
}
return 'unknown';
}
/**
* Truncate ClamAV output message for log safety.
*/
private static function truncateLogMessage(string $msg, int $max): string
{
if (mb_strlen($msg, 'UTF-8') <= $max) {
return $msg;
}
return mb_substr($msg, 0, $max, 'UTF-8') . '…';
}
}
+24
View File
@@ -39,6 +39,14 @@ if [ "${PERSISTENT_TOKENS_KEY:-}" = "default_please_change_this_key" ] || [ -z "
echo "⚠️ WARNING: Using default/empty persistent tokens key—override for production."
fi
# 1.5) Log virus-scan configuration (purely informational)
if [ "${VIRUS_SCAN_ENABLED:-false}" = "true" ]; then
echo "[startup] VIRUS_SCAN_ENABLED=true"
echo "[startup] Using virus scanner command: ${VIRUS_SCAN_CMD:-clamscan}"
else
echo "[startup] Virus scanning disabled (VIRUS_SCAN_ENABLED != 'true')."
fi
# 2) Update config.php based on environment variables
CONFIG_FILE="/var/www/config/config.php"
if [ -f "${CONFIG_FILE}" ]; then
@@ -82,6 +90,22 @@ post_max_size = ${TOTAL_UPLOAD_SIZE}
EOF
fi
# 3.3) Update ClamAV signatures if not explicitly disabled
if [ "${CLAMAV_AUTO_UPDATE:-true}" = "true" ]; then
if command -v freshclam >/dev/null 2>&1; then
if [ "$(id -u)" -eq 0 ]; then
echo "[startup] Updating ClamAV signatures via freshclam..."
freshclam || echo "[startup] freshclam failed; continuing with existing signatures (if any)."
else
echo "[startup] Not running as root; skipping freshclam (requires root)."
fi
else
echo "[startup] ClamAV installed but 'freshclam' not found; skipping DB update."
fi
else
echo "[startup] CLAMAV_AUTO_UPDATE=false; skipping freshclam."
fi
# 4) Adjust Apache LimitRequestBody
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
size_str="$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')"