mirror of
https://github.com/error311/FileRise.git
synced 2026-05-05 19:40:30 -05:00
release(v2.5.0): add optional ClamAV upload, share upload & portal upload scanning and Pro virus log
This commit is contained in:
@@ -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
@@ -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; \
|
||||
|
||||
@@ -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. Unraid’s 99). |
|
||||
| `PGID` | Optional | `100` | If running as root, remap `www-data` group to this GID (e.g. Unraid’s 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 Apache’s `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`).
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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, '&')
|
||||
.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 = `
|
||||
<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"> </td></tr>
|
||||
<tr><td colspan="5"> </td></tr>
|
||||
<tr><td colspan="5"> </td></tr>
|
||||
<tr><td colspan="5"> </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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
+1085
-870
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
// Don’t 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);
|
||||
// Don’t 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
@@ -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') . '…';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:]')"
|
||||
|
||||
Reference in New Issue
Block a user