release(v3.2.3): resumable upload UX fixes + stale chunk cleanup + folder re-upload conflict handling (closes #100, closes #101, closes #102)

- uploads: fix resumable resume banner layout for long filenames + improve dismiss behavior
- uploads: add preflight check existing files flow for folder uploads (resume+skip+overwrite)
- cleanup: add resumable TTL (Admin + env) + background sweeps + admin CLI cleanup tools
- folders: allow deleting empty folders by cleaning resumable temp dirs first
- docs: update OpenAPI (uploads config, checkExisting endpoint, cleanup endpoint)
This commit is contained in:
Ryan
2026-01-29 01:02:46 -05:00
committed by GitHub
parent 6795b5df51
commit 707471aee7
16 changed files with 1457 additions and 20 deletions
+51 -1
View File
@@ -1,6 +1,56 @@
# Changelog
## Changees 01/28/2026 (v3.2.2)
## Changes 01/29/2026 (v3.2.3)
`release(v3.2.3): resumable upload UX fixes + stale chunk cleanup + folder re-upload conflict handling (closes #100, closes #101, closes #102)`
**Commit message**
```text
release(v3.2.3): resumable upload UX fixes + stale chunk cleanup + folder re-upload conflict handling (closes #100, closes #101, closes #102)
- uploads: fix resumable resume banner layout for long filenames + improve dismiss behavior
- uploads: add preflight check existing files flow for folder uploads (resume+skip+overwrite)
- cleanup: add resumable TTL (Admin + env) + background sweeps + admin CLI cleanup tools
- folders: allow deleting empty folders by cleaning resumable temp dirs first
- docs: update OpenAPI (uploads config, checkExisting endpoint, cleanup endpoint)
```
**Fixed**
- **#100:** Resumable resume banner **Dismiss** button is now reliably visible even with very long filenames.
- Wrapped banner content and forced safe word wrapping so long names dont push the button off-screen.
- **#101:** You can now delete a folder that only contains unfinished resumable chunks (refresh → dismiss → folder looked empty but wouldnt delete).
- Folder delete now cleans `resumable_*` temp dirs for that folder before the “is empty” check.
- **#102:** Re-uploading a folder after an interruption no longer blindly re-uploads files that already exist.
- New “Existing files detected” modal lets users choose: **Resume** (skip same-size), **Skip existing**, or **Overwrite**.
**Added**
- **Upload preflight endpoint:** `POST /api/upload/checkExisting.php`
- Checks a list of relative paths and reports which already exist (and whether size matches).
- Supports `sourceId` when Sources is enabled.
- **Resumable cleanup controls**
- Admin setting: **Resumable cleanup age (hours)** (`uploads.resumableTtlHours`, default 6h)
- Admin action: **Run cleanup now** (`POST /api/admin/resumableCleanup.php`)
- CLI tool: `src/cli/resumable_cleanup.php` (supports `--all`, `--source`, `--respect-interval`)
**Changed**
- **Resumable drafts banner UX**
- Banner copy now explains how to resume and that Dismiss clears partial uploads + temp files.
- Dismiss now attempts cleanup via `removeChunks` for all pending identifiers in the current folder.
- **Resumable temp management**
- Tracks folders with pending resumable temp dirs via a small index (`resumable_pending.json`)
- Performs periodic TTL-based sweeps (rate-limited) to remove stale temp folders automatically.
- **Admin config / siteConfig**
- `uploads.resumableTtlHours` is now included in config payloads.
- **OpenAPI**
- Docs updated for uploads config, `checkExisting`, and admin cleanup endpoints.
---
## Changes 01/28/2026 (v3.2.2)
`release(v3.2.2): update OpenAPI spec to match shipped endpoints`
+155 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "FileRise API",
"version": "3.2.2"
"version": "3.2.3"
},
"servers": [
{
@@ -6239,6 +6239,118 @@
]
}
},
"/api/upload/checkExisting.php": {
"post": {
"tags": [
"Uploads"
],
"summary": "Check for existing files before upload",
"description": "Checks whether the provided relative paths already exist in the target folder.",
"operationId": "checkUploadExisting",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"folder",
"files"
],
"properties": {
"folder": {
"type": "string",
"example": "root"
},
"sourceId": {
"type": "string",
"example": "local"
},
"files": {
"type": "array",
"items": {
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"example": "team/reports/report.pdf"
},
"size": {
"type": "integer",
"format": "int64",
"example": 123456
}
},
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Existing files",
"content": {
"application/json": {
"schema": {
"properties": {
"existing": {
"type": "array",
"items": {
"properties": {
"path": {
"type": "string"
},
"size": {
"type": "integer",
"format": "int64",
"nullable": true
},
"sameSize": {
"type": "boolean",
"nullable": true
}
},
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"400": {
"description": "Invalid input"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Invalid CSRF token"
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/upload/removeChunks.php": {
"post": {
"tags": [
@@ -6614,6 +6726,7 @@
"globalOtpauthUrl",
"enableWebDAV",
"sharedMaxUploadSize",
"uploads",
"oidc"
],
"properties": {
@@ -6634,6 +6747,26 @@
"type": "integer",
"format": "int64"
},
"uploads": {
"type": "object",
"additionalProperties": false,
"properties": {
"resumableChunkMb": {
"type": "number",
"format": "float",
"minimum": 0.5,
"maximum": 100,
"example": 1.5
},
"resumableTtlHours": {
"type": "number",
"format": "float",
"minimum": 0.5,
"maximum": 168,
"example": 6
}
}
},
"oidc": {
"$ref": "#/components/schemas/OIDCConfigPublic"
}
@@ -6712,6 +6845,26 @@
"minimum": 0,
"example": 52428800
},
"uploads": {
"type": "object",
"additionalProperties": false,
"properties": {
"resumableChunkMb": {
"type": "number",
"format": "float",
"minimum": 0.5,
"maximum": 100,
"example": 1.5
},
"resumableTtlHours": {
"type": "number",
"format": "float",
"minimum": 0.5,
"maximum": 168,
"example": 6
}
}
},
"oidc": {
"description": "When disableOIDCLogin=false (OIDC enabled), providerUrl, redirectUri, and clientId are required.",
"properties": {
@@ -6887,4 +7040,4 @@
"description": "Uploads"
}
]
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
// public/api/admin/resumableCleanup.php
/**
* @OA\Post(
* path="/api/admin/resumableCleanup.php",
* summary="Run resumable upload cleanup sweep",
* description="Deletes expired resumable upload temp folders using the configured TTL.",
* operationId="adminResumableCleanup",
* tags={"Admin"},
* security={{"cookieAuth": {}, "CsrfHeader": {}}},
*
* @OA\RequestBody(
* required=false,
* @OA\JsonContent(
* type="object",
* @OA\Property(property="all", type="boolean", example=true, description="Sweep all sources when supported"),
* @OA\Property(property="purgeAll", type="boolean", example=true, description="Remove all resumable temp folders, ignoring TTL"),
* @OA\Property(property="sourceId", type="string", example="local", description="Optional source id to sweep")
* )
* ),
*
* @OA\Response(
* response=200,
* description="Cleanup results",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="checked", type="integer", example=12),
* @OA\Property(property="removed", type="integer", example=3),
* @OA\Property(property="remaining", type="integer", example=2),
* @OA\Property(property="sources", type="integer", example=1)
* )
* ),
* @OA\Response(response=400, description="Bad request"),
* @OA\Response(response=403, description="Forbidden"),
* @OA\Response(response=405, description="Method not allowed"),
* @OA\Response(response=500, description="Server error")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
AdminController::resumableCleanup();
+60
View File
@@ -0,0 +1,60 @@
<?php
// public/api/upload/checkExisting.php
/**
* @OA\Post(
* path="/api/upload/checkExisting.php",
* summary="Check for existing files before upload",
* description="Checks whether the provided relative paths already exist in the target folder.",
* operationId="checkUploadExisting",
* tags={"Uploads"},
* security={{"cookieAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="application/json",
* @OA\Schema(
* required={"folder","files"},
* @OA\Property(property="folder", type="string", example="root"),
* @OA\Property(property="sourceId", type="string", example="local"),
* @OA\Property(
* property="files",
* type="array",
* @OA\Items(
* type="object",
* required={"path"},
* @OA\Property(property="path", type="string", example="team/reports/report.pdf"),
* @OA\Property(property="size", type="integer", format="int64", example=123456)
* )
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Existing files",
* @OA\JsonContent(
* type="object",
* @OA\Property(
* property="existing",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="path", type="string"),
* @OA\Property(property="size", type="integer", format="int64", nullable=true),
* @OA\Property(property="sameSize", type="boolean", nullable=true)
* )
* )
* )
* ),
* @OA\Response(response=400, description="Invalid input"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=403, description="Invalid CSRF token")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
$uploadController = new UploadController();
$uploadController->checkExisting();
+19 -3
View File
@@ -1748,17 +1748,24 @@ label{font-size: 0.9rem;}
font-size: 16px !important;
line-height: 1.2;
margin-left: 8px;}
.upload-drop-area{text-align: center;}
.upload-instruction{margin-bottom: 10px;
font-size: 16px;}
font-size: 16px;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;}
.upload-file-row{display: flex;
align-items: center;
justify-content: center;
word-break: break-word;}
word-break: break-word;
flex-wrap: wrap;
max-width: 100%;}
.file-info-wrapper{display: flex;
flex-direction: column;
justify-content: center !important;
align-items: center !important;
margin-top: 10px;}
margin-top: 10px;
max-width: 100%;}
.file-info-container{display: flex;
flex-wrap: wrap !important;
justify-content: center !important;
@@ -2671,12 +2678,21 @@ body.dark-mode :is(#fileList, #fileListSecondary) .folder-svg .enc-mark-keyhole{
#resumableDraftBanner.upload-resume-banner{margin: 8px 12px 12px;}
.upload-resume-banner-inner{display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(255, 152, 0, 0.06);
border: 1px solid rgba(255, 152, 0, 0.55);
font-size: 0.9rem;}
.upload-resume-text{flex: 1 1 240px;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;}
.upload-resume-text .upload-resume-name{overflow-wrap: anywhere;}
.upload-resume-dismiss-btn{flex: 0 0 auto;
margin-left: auto;}
.upload-resume-banner-inner .material-icons,
.folder-badge .material-icons{font-size: 20px;
margin-right: 6px;
+143
View File
@@ -644,6 +644,71 @@ function wireClamavTestButton(scope = document) {
});
}
function wireResumableCleanupButton(scope = document) {
const btn = scope.querySelector('#resumableCleanupNowBtn');
const statusEl = scope.querySelector('#resumableCleanupStatus');
if (!btn || !statusEl || btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', async () => {
btn.disabled = true;
statusEl.textContent = t('resumable_cleanup_running') || 'Running cleanup...';
statusEl.className = 'small text-muted';
try {
const res = await fetch('/api/admin/resumableCleanup.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
},
body: JSON.stringify({ all: true, purgeAll: true })
});
const data = await safeJson(res);
if (!data || data.success !== true) {
const msg =
(data && (data.error || data.message)) ||
t('resumable_cleanup_failed') ||
'Resumable cleanup failed.';
statusEl.textContent = msg;
statusEl.className = 'small text-danger';
showToast(msg, 'error');
return;
}
const checked = parseInt(data.checked || 0, 10) || 0;
const removed = parseInt(data.removed || 0, 10) || 0;
const remaining = parseInt(data.remaining || 0, 10) || 0;
const sources = parseInt(data.sources || 1, 10) || 1;
const msg = sources > 1
? (t('resumable_cleanup_done_sources', { removed, remaining, checked, sources })
|| `Cleanup complete: removed ${removed}, remaining ${remaining}, checked ${checked} across ${sources} sources.`)
: (t('resumable_cleanup_done', { removed, remaining, checked })
|| `Cleanup complete: removed ${removed}, remaining ${remaining}, checked ${checked}.`);
statusEl.textContent = msg;
statusEl.className = 'small text-success';
showToast(msg, 'success');
} catch (e) {
console.error('Resumable cleanup error', e);
const msg =
(e && e.message ? e.message : '') ||
t('resumable_cleanup_failed') ||
'Resumable cleanup failed.';
statusEl.textContent = msg;
statusEl.className = 'small text-danger';
showToast(msg, 'error');
} finally {
btn.disabled = false;
}
});
}
function wireIgnoreRegexPresetButton(scope = document) {
const btn = scope.querySelector('#ignoreRegexSnapshotsPreset');
const input = scope.querySelector('#ignoreRegex');
@@ -2534,6 +2599,7 @@ function captureInitialAdminConfig() {
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
resumableChunkMb: (document.getElementById("resumableChunkMb")?.value || "").trim(),
resumableTtlHours: (document.getElementById("resumableTtlHours")?.value || "").trim(),
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(),
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
@@ -2595,6 +2661,7 @@ function hasUnsavedChanges() {
getChk("enableWebDAV") !== o.enableWebDAV ||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
getVal("resumableChunkMb") !== o.resumableChunkMb ||
getVal("resumableTtlHours") !== o.resumableTtlHours ||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
@@ -5112,6 +5179,59 @@ export function openAdminPanel() {
</small>
</div>
<div class="form-group" style="margin-top:10px;">
<label for="resumableTtlHours">
${tf("resumable_cleanup_hours_label", "Resumable cleanup age (hours)")}
</label>
<input
type="number"
id="resumableTtlHours"
class="form-control"
min="0.5"
max="168"
step="0.5"
/>
<small class="text-muted d-block mt-1">
${tf(
"resumable_cleanup_hours_help",
"Deletes unfinished resumable uploads after this age. Applies to background sweeps and manual cleanup."
)}
</small>
</div>
<div class="mt-2">
<button
type="button"
id="resumableCleanupNowBtn"
class="btn btn-sm btn-secondary">
${tf("resumable_cleanup_run_now", "Run cleanup now")}
</button>
<small class="text-muted d-block" style="margin-top:4px;">
${tf(
"resumable_cleanup_run_now_help",
"Immediately sweeps expired resumable temp folders using the age above."
)}
</small>
<div id="resumableCleanupStatus" class="small text-muted" style="margin-top:4px;"></div>
<div class="small text-muted" style="margin-top:6px;">
<div style="font-weight:600;">
${tf("resumable_cleanup_cron_title", "Cron example")}
</div>
<div>
${tf(
"resumable_cleanup_cron_help",
"Example hourly job: <code>0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval</code>"
)}
</div>
<div>
${tf(
"resumable_cleanup_cron_note",
"Replace /path/to/FileRise with your install path."
)}
</div>
</div>
</div>
<hr class="admin-divider">
<div class="admin-subsection-title" style="margin-top:2px;">
@@ -5300,6 +5420,7 @@ export function openAdminPanel() {
const headerSettingsScope = document.getElementById("headerSettingsContent");
wireIgnoreRegexPresetButton(headerSettingsScope);
wireClamavTestButton(uploadScope);
wireResumableCleanupButton(uploadScope);
initVirusLogUI({ isPro });
// ONLYOFFICE section (moved into adminOnlyOffice.js)
initOnlyOfficeUI({ config });
@@ -6450,6 +6571,13 @@ ${t("shared_max_upload_size_bytes")}
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
chunkEl.value = val;
}
const ttlEl = document.getElementById("resumableTtlHours");
if (ttlEl) {
const raw = uploadCfg.resumableTtlHours;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(168, Math.max(0.5, num)) : 6;
ttlEl.value = val;
}
// Published URL (optional)
const deploy = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
@@ -6549,6 +6677,13 @@ ${t("shared_max_upload_size_bytes")}
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
chunkEl2.value = val;
}
const ttlEl2 = document.getElementById("resumableTtlHours");
if (ttlEl2) {
const raw = uploadCfg2.resumableTtlHours;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(168, Math.max(0.5, num)) : 6;
ttlEl2.value = val;
}
// Published URL (optional)
const deploy2 = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
@@ -6634,6 +6769,7 @@ ${t("shared_max_upload_size_bytes")}
const headerSettingsScope = document.getElementById("headerSettingsContent");
wireIgnoreRegexPresetButton(headerSettingsScope);
wireClamavTestButton(uploadScope);
wireResumableCleanupButton(uploadScope);
initVirusLogUI({ isPro });
renderAdminEncryptionSection({ config, dark });
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
@@ -6838,6 +6974,13 @@ function handleSave() {
parseFloat(document.getElementById("resumableChunkMb")?.value || "1.5") || 1.5
)
),
resumableTtlHours: Math.max(
0.5,
Math.min(
168,
parseFloat(document.getElementById("resumableTtlHours")?.value || "6") || 6
)
),
},
clamav: {
scanUploads: document.getElementById("clamavScanUploads").checked,
+1 -1
View File
@@ -1538,7 +1538,7 @@ function isFtpSource(sourceId = '') {
function isSlowRemoteSource(sourceId = '') {
const type = String(getSourceTypeById(sourceId || getGlobalActiveSourceId()) || '').toLowerCase();
if (!type || type === 'local') return false;
return ['ftp', 'sftp', 'webdav', 'smb', 'gdrive', 'onedrive', 'dropbox'].includes(type);
return ['ftp', 'sftp', 'webdav'].includes(type);
}
function getSourceMetaById(sourceId) {
+18
View File
@@ -57,6 +57,24 @@ const translations = {
"upload_instruction": "Drop files/folders here or click 'Choose files'",
"no_files_selected_default": "No files selected",
"choose_files": "Choose files",
"upload_conflict_title": "Existing files detected",
"upload_conflict_message": "Found {existing} of {total} files already in this folder ({same} same size, {diff} different size). Resume skips same-size files, Skip ignores all existing files, Overwrite reuploads everything.",
"upload_conflict_resume": "Resume",
"upload_conflict_skip": "Skip existing",
"upload_conflict_overwrite": "Overwrite",
"upload_conflict_all_skipped": "All selected files already exist.",
"upload_conflict_skipped": "Skipped {count} existing file(s).",
"resumable_cleanup_hours_label": "Resumable cleanup age (hours)",
"resumable_cleanup_hours_help": "Deletes unfinished resumable uploads after this age. Applies to background sweeps and manual cleanup.",
"resumable_cleanup_run_now": "Run cleanup now",
"resumable_cleanup_run_now_help": "Immediately clears resumable temp folders, ignoring the age above.",
"resumable_cleanup_running": "Running cleanup...",
"resumable_cleanup_done": "Cleanup complete: removed {removed}, remaining {remaining}, checked {checked}.",
"resumable_cleanup_done_sources": "Cleanup complete: removed {removed}, remaining {remaining}, checked {checked} across {sources} sources.",
"resumable_cleanup_failed": "Resumable cleanup failed.",
"resumable_cleanup_cron_title": "Cron example",
"resumable_cleanup_cron_help": "Example hourly job: <code>0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval</code>",
"resumable_cleanup_cron_note": "Replace /path/to/FileRise with your install path.",
"delete_selected": "Delete Selected",
"copy_selected": "Copy Selected",
"move_selected": "Move Selected",
+263 -5
View File
@@ -8,6 +8,7 @@ import { withBase } from './basePath.js?v={{APP_QVER}}';
const UPLOAD_URL = withBase('/api/upload/upload.php');
const RESUMABLE_TARGET = UPLOAD_URL;
const CHECK_EXISTING_URL = withBase('/api/upload/checkExisting.php');
function getActiveUploadSourceId() {
const paneKey = window.activePane === 'secondary' ? 'secondary' : 'primary';
@@ -385,6 +386,14 @@ function showResumableDraftBanner() {
count === 1
? 'You have a partially uploaded file'
: `You have ${count} partially uploaded files. Latest:`;
const resumeHint =
count === 1
? 'Choose it again from your device to resume.'
: 'Choose them again from your device to resume.';
const dismissHint = 'Dismiss clears the partial uploads and temporary files.';
const cleanupIds = candidates
.map(entry => entry && entry.identifier)
.filter(Boolean);
const banner = document.createElement('div');
banner.id = 'resumableDraftBanner';
@@ -394,9 +403,10 @@ function showResumableDraftBanner() {
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
<span class="upload-resume-text">
${countText}
<strong>${escapeHTML(latest.fileName)}</strong>
<strong class="upload-resume-name">${escapeHTML(latest.fileName)}</strong>
(~${latest.lastPercent}%).
Choose it again from your device to resume.
${resumeHint}
${dismissHint}
</span>
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
</div>
@@ -407,6 +417,11 @@ function showResumableDraftBanner() {
dismissBtn.addEventListener('click', () => {
// Clear all resumable hints for this folder when the user dismisses.
clearResumableDraftsForFolder(folder);
if (window.csrfToken && cleanupIds.length) {
cleanupIds.forEach(identifier => {
removeChunkFolderRepeatedly(identifier, window.csrfToken, folder, 2, 800);
});
}
if (banner.parentNode) {
banner.parentNode.removeChild(banner);
}
@@ -592,9 +607,238 @@ function applyResumableRelativePath(file) {
}
}
function normalizeUploadPath(raw) {
return String(raw || '')
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.trim();
}
function getUploadPathForFile(file) {
if (!file || typeof file !== 'object') return '';
const raw =
file.customRelativePath ||
file.relativePath ||
file.webkitRelativePath ||
file.name ||
file.fileName ||
'';
return normalizeUploadPath(raw);
}
function ensureUploadConflictModal() {
let modal = document.getElementById('uploadConflictModal');
if (modal) return modal;
modal = document.createElement('div');
modal.id = 'uploadConflictModal';
modal.className = 'modal';
modal.style.display = 'none';
modal.innerHTML = `
<div class="modal-content">
<h4 id="uploadConflictTitle"></h4>
<p id="uploadConflictMessage"></p>
<div class="button-container" style="flex-wrap: wrap; justify-content: flex-end;">
<button id="uploadConflictResume" class="btn btn-primary"></button>
<button id="uploadConflictSkip" class="btn btn-secondary"></button>
<button id="uploadConflictOverwrite" class="btn btn-danger"></button>
<button id="uploadConflictCancel" class="btn btn-secondary"></button>
</div>
</div>
`;
document.body.appendChild(modal);
return modal;
}
function showUploadConflictModal(stats) {
return new Promise((resolve) => {
const modal = ensureUploadConflictModal();
const titleEl = modal.querySelector('#uploadConflictTitle');
const msgEl = modal.querySelector('#uploadConflictMessage');
const resumeBtn = modal.querySelector('#uploadConflictResume');
const skipBtn = modal.querySelector('#uploadConflictSkip');
const overwriteBtn = modal.querySelector('#uploadConflictOverwrite');
const cancelBtn = modal.querySelector('#uploadConflictCancel');
const total = stats?.total || 0;
const existing = stats?.existing || 0;
const sameSize = stats?.sameSize || 0;
const diffSize = stats?.diffSize || 0;
const titleText = t('upload_conflict_title') || 'Existing files detected';
const msgText = t('upload_conflict_message', {
existing,
total,
same: sameSize,
diff: diffSize
}) || `Found ${existing} of ${total} files already in this folder.`;
titleEl.textContent = titleText;
msgEl.textContent = msgText;
resumeBtn.textContent = t('upload_conflict_resume') || 'Resume';
skipBtn.textContent = t('upload_conflict_skip') || 'Skip existing';
overwriteBtn.textContent = t('upload_conflict_overwrite') || 'Overwrite';
cancelBtn.textContent = t('cancel') || 'Cancel';
modal.style.display = 'block';
function cleanup(choice) {
modal.style.display = 'none';
resumeBtn.removeEventListener('click', onResume);
skipBtn.removeEventListener('click', onSkip);
overwriteBtn.removeEventListener('click', onOverwrite);
cancelBtn.removeEventListener('click', onCancel);
resolve(choice);
}
function onResume() { cleanup('resume'); }
function onSkip() { cleanup('skip'); }
function onOverwrite() { cleanup('overwrite'); }
function onCancel() { cleanup('cancel'); }
resumeBtn.addEventListener('click', onResume);
skipBtn.addEventListener('click', onSkip);
overwriteBtn.addEventListener('click', onOverwrite);
cancelBtn.addEventListener('click', onCancel);
});
}
async function fetchExistingUploads(payload, retry = true) {
try {
const res = await fetch(CHECK_EXISTING_URL, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
},
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => null);
if (data && data.csrf_expired && data.csrf_token && retry) {
window.csrfToken = data.csrf_token;
return fetchExistingUploads(payload, false);
}
if (!res.ok || !data || typeof data !== 'object' || data.error) {
return null;
}
return data;
} catch (e) {
console.warn('Upload existence check failed:', e);
return null;
}
}
async function filterExistingUploads(files) {
const result = { files, autoStart: false };
if (!Array.isArray(files) || files.length === 0) return result;
if (!hasFolderPaths(files)) return result;
const payloadFiles = [];
files.forEach(file => {
const path = getUploadPathForFile(file);
if (!path) return;
const size = typeof file.size === 'number'
? file.size
: (file.file && typeof file.file.size === 'number' ? file.file.size : null);
payloadFiles.push({ path, size });
});
if (!payloadFiles.length) return result;
const payload = {
folder: window.currentFolder || 'root',
files: payloadFiles
};
const sourceId = getActiveUploadSourceId();
if (sourceId) payload.sourceId = sourceId;
const existingResult = await fetchExistingUploads(payload);
if (!existingResult || !Array.isArray(existingResult.existing) || existingResult.existing.length === 0) {
return result;
}
const existing = existingResult.existing;
const existingCount = existing.length;
const sameCount = existing.filter(e => e && e.sameSize === true).length;
const diffCount = existingCount - sameCount;
const choice = await showUploadConflictModal({
total: payloadFiles.length,
existing: existingCount,
sameSize: sameCount,
diffSize: diffCount
});
if (choice === 'cancel') return { files: [], autoStart: false };
if (choice === 'overwrite') return { files, autoStart: true };
const existingAny = new Set(existing.map(e => normalizeUploadPath(e.path)));
const existingSame = new Set(
existing.filter(e => e && e.sameSize === true).map(e => normalizeUploadPath(e.path))
);
const filtered = files.filter(file => {
const path = getUploadPathForFile(file);
if (!path) return true;
if (choice === 'skip') {
return !existingAny.has(path);
}
return !existingSame.has(path);
});
if (filtered.length === 0) {
showToast(t('upload_conflict_all_skipped') || 'All selected files already exist.', 'info');
return { files: filtered, autoStart: false };
}
const skipped = files.length - filtered.length;
if (skipped > 0) {
const msg = t('upload_conflict_skipped', { count: skipped }) || `Skipped ${skipped} existing file(s).`;
showToast(msg, 'info');
}
return { files: filtered, autoStart: true };
}
function startResumableUploadNow() {
if (!resumableInstance) return;
if (!Array.isArray(resumableInstance.files) || resumableInstance.files.length === 0) {
return;
}
if (typeof resumableInstance.isUploading === 'function' && resumableInstance.isUploading()) {
return;
}
setUploadButtonVisible(false);
showVirusScanNotice();
resumableInstance.opts.headers = resumableInstance.opts.headers || {};
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
if (typeof resumableInstance.opts.query !== 'function') {
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.opts.query.upload_token = window.csrfToken;
const sourceId = getActiveUploadSourceId();
if (sourceId) {
resumableInstance.opts.query.sourceId = sourceId;
} else {
delete resumableInstance.opts.query.sourceId;
}
}
resumableInstance.upload();
showToast(t('upload_resumable_started') || 'Resumable upload started...', 'info');
}
async function queueResumableFiles(files) {
const filteredResult = await filterExistingUploads(files);
const filtered = filteredResult && Array.isArray(filteredResult.files)
? filteredResult.files
: [];
const autoStart = !!filteredResult?.autoStart;
if (!filtered || filtered.length === 0) return;
if (!useResumable) {
processFiles(files);
processFiles(filtered);
return;
}
@@ -605,14 +849,27 @@ async function queueResumableFiles(files) {
if (!_resumableReady) await initResumableUpload();
if (!resumableInstance) {
// If Resumable failed to load, fall back to XHR
processFiles(files);
processFiles(filtered);
return;
}
files.forEach(file => {
if (_autoStartResumableTimer) {
clearTimeout(_autoStartResumableTimer);
_autoStartResumableTimer = null;
}
filtered.forEach(file => {
applyResumableRelativePath(file);
resumableInstance.addFile(file);
});
if (autoStart && resumableInstance) {
// Defer until chunks are bootstrapped (Resumable builds chunks async).
_autoStartResumableTimer = setTimeout(() => {
_autoStartResumableTimer = null;
startResumableUploadNow();
}, 0);
}
}
// Helper function to repeatedly call removeChunks.php
@@ -914,6 +1171,7 @@ let resumableInstance = null;
let _pendingPickedFiles = []; // files picked before library/instance ready
let _resumableReady = false;
let _currentResumableIds = new Set();
let _autoStartResumableTimer = null;
// Make init async-safe; it resolves when Resumable is constructed
async function initResumableUpload() {
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
// src/cli/resumable_cleanup.php
//
// Sweep expired resumable_* upload temp folders based on configured TTL.
require __DIR__ . '/../../config/config.php';
require __DIR__ . '/../../src/models/UploadModel.php';
require __DIR__ . '/../../src/lib/SourceContext.php';
$sourceId = '';
$allSources = false;
$force = true;
foreach ($argv as $i => $arg) {
if ($i === 0) {
continue;
}
if ($arg === '--all') {
$allSources = true;
continue;
}
if ($arg === '--respect-interval') {
$force = false;
continue;
}
if (str_starts_with($arg, '--source=')) {
$sourceId = trim(substr($arg, strlen('--source=')));
continue;
}
if ($arg === '--source' && isset($argv[$i + 1])) {
$sourceId = trim((string)$argv[$i + 1]);
continue;
}
if ($arg === '--help' || $arg === '-h') {
$msg = "Usage: php src/cli/resumable_cleanup.php [--all] [--source <id>] [--respect-interval]\n";
fwrite(STDOUT, $msg);
exit(0);
}
}
try {
if ($allSources) {
$totals = ['checked' => 0, 'removed' => 0, 'remaining' => 0];
$sources = SourceContext::listAllSources();
foreach ($sources as $src) {
$id = (string)($src['id'] ?? '');
if ($id !== '') {
SourceContext::setActiveId($id, false, true);
}
$res = UploadModel::sweepResumableExpired($force);
$totals['checked'] += (int)($res['checked'] ?? 0);
$totals['removed'] += (int)($res['removed'] ?? 0);
$totals['remaining'] += (int)($res['remaining'] ?? 0);
}
$msg = sprintf(
"Resumable cleanup complete (all sources): checked=%d removed=%d remaining=%d\n",
$totals['checked'],
$totals['removed'],
$totals['remaining']
);
fwrite(STDOUT, $msg);
exit(0);
}
if ($sourceId !== '') {
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
fwrite(STDERR, "Invalid source id.\n");
exit(1);
}
SourceContext::setActiveId($sourceId, false, true);
}
$res = UploadModel::sweepResumableExpired($force);
$msg = sprintf(
"Resumable cleanup complete: checked=%d removed=%d remaining=%d\n",
(int)($res['checked'] ?? 0),
(int)($res['removed'] ?? 0),
(int)($res['remaining'] ?? 0)
);
fwrite(STDOUT, $msg);
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, "Resumable cleanup error: " . $e->getMessage() . "\n");
exit(1);
}
+95
View File
@@ -797,6 +797,94 @@ class AdminController
]);
}
public static function resumableCleanup(): void
{
header('Content-Type: application/json; charset=utf-8');
self::requireAdmin();
self::requireCsrf();
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed.']);
return;
}
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
$raw = file_get_contents('php://input');
$payload = json_decode($raw ?: '{}', true);
if (!is_array($payload)) {
$payload = $_POST ?? [];
}
$all = !empty($payload['all']);
$purgeAll = !empty($payload['purgeAll']);
$sourceId = trim((string)($payload['sourceId'] ?? ''));
if ($sourceId !== '' && !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid source id.']);
return;
}
$totals = ['checked' => 0, 'removed' => 0, 'remaining' => 0];
$sourcesProcessed = 0;
$activeId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';
try {
if ($all && class_exists('SourceContext') && method_exists('SourceContext', 'listAllSources')) {
$sources = SourceContext::listAllSources();
foreach ($sources as $src) {
$id = (string)($src['id'] ?? '');
if ($id !== '') {
SourceContext::setActiveId($id, false, true);
}
$res = $purgeAll
? UploadModel::purgeResumableAll()
: UploadModel::sweepResumableExpired(true);
$totals['checked'] += (int)($res['checked'] ?? 0);
$totals['removed'] += (int)($res['removed'] ?? 0);
$totals['remaining'] += (int)($res['remaining'] ?? 0);
$sourcesProcessed++;
}
} else {
if ($sourceId !== '' && class_exists('SourceContext')) {
SourceContext::setActiveId($sourceId, false, true);
}
$res = $purgeAll
? UploadModel::purgeResumableAll()
: UploadModel::sweepResumableExpired(true);
$totals['checked'] = (int)($res['checked'] ?? 0);
$totals['removed'] = (int)($res['removed'] ?? 0);
$totals['remaining'] = (int)($res['remaining'] ?? 0);
$sourcesProcessed = 1;
}
if ($activeId !== '' && class_exists('SourceContext')) {
SourceContext::setActiveId($activeId, false, true);
}
if ($sourcesProcessed === 0) {
$sourcesProcessed = 1;
}
echo json_encode([
'success' => true,
'checked' => $totals['checked'],
'removed' => $totals['removed'],
'remaining' => $totals['remaining'],
'sources' => $sourcesProcessed,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable $e) {
if ($activeId !== '' && class_exists('SourceContext')) {
SourceContext::setActiveId($activeId, false, true);
}
http_response_code(500);
echo json_encode(['error' => 'Cleanup failed: ' . $e->getMessage()]);
}
}
public function setLicense(): void
{
// Always respond JSON
@@ -2322,6 +2410,7 @@ class AdminController
'ignoreRegex' => '',
'uploads' => [
'resumableChunkMb' => 1.5,
'resumableTtlHours' => 6.0,
],
'oidc' => [
'providerUrl' => '',
@@ -2465,6 +2554,7 @@ class AdminController
if (!isset($merged['uploads']) || !is_array($merged['uploads'])) {
$merged['uploads'] = [
'resumableChunkMb' => 1.5,
'resumableTtlHours' => 6.0,
];
}
if (array_key_exists('resumableChunkMb', $data['uploads'])) {
@@ -2472,6 +2562,11 @@ class AdminController
$num = is_numeric($raw) ? (float)$raw : 1.5;
$merged['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
}
if (array_key_exists('resumableTtlHours', $data['uploads'])) {
$raw = $data['uploads']['resumableTtlHours'];
$num = is_numeric($raw) ? (float)$raw : 6.0;
$merged['uploads']['resumableTtlHours'] = max(0.5, min(168, $num));
}
}
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
+97
View File
@@ -267,4 +267,101 @@ class UploadController
echo json_encode(UploadModel::removeChunks($folder));
}
public function checkExisting(): void
{
header('Content-Type: application/json');
$raw = file_get_contents('php://input') ?: '';
$payload = json_decode($raw, true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON payload.']);
return;
}
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($payload['csrf_token'])) {
$received = trim((string)$payload['csrf_token']);
} elseif (!empty($payload['upload_token'])) {
$received = trim((string)$payload['upload_token']);
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token'],
]);
return;
}
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
$sourceId = trim((string)($payload['sourceId'] ?? ''));
if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid source id.']);
return;
}
$info = SourceContext::getSourceById($sourceId);
if (!$info) {
http_response_code(400);
echo json_encode(['error' => 'Invalid source.']);
return;
}
if (empty($info['enabled']) && !$isAdmin) {
http_response_code(403);
echo json_encode(['error' => 'Source is disabled.']);
return;
}
SourceContext::setActiveId($sourceId, false, $isAdmin);
}
if (class_exists('SourceContext') && SourceContext::isReadOnly()) {
http_response_code(403);
echo json_encode(['error' => 'Source is read-only.']);
return;
}
$folderParam = isset($payload['folder']) ? (string)$payload['folder'] : 'root';
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return;
}
$files = $payload['files'] ?? null;
if (!is_array($files)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid files list.']);
return;
}
echo json_encode(UploadModel::checkExisting($targetFolder, $files));
}
}
+13
View File
@@ -205,8 +205,12 @@ class AdminModel
$resumableChunkMb = (isset($uploadsCfg['resumableChunkMb']) && is_numeric($uploadsCfg['resumableChunkMb']))
? (float)$uploadsCfg['resumableChunkMb']
: 1.5;
$resumableTtlHours = (isset($uploadsCfg['resumableTtlHours']) && is_numeric($uploadsCfg['resumableTtlHours']))
? (float)$uploadsCfg['resumableTtlHours']
: 6.0;
$public['uploads'] = [
'resumableChunkMb' => max(0.5, min(100, $resumableChunkMb)),
'resumableTtlHours' => max(0.5, min(168, $resumableTtlHours)),
];
$displayCfg = (isset($config['display']) && is_array($config['display']))
? $config['display']
@@ -454,11 +458,15 @@ class AdminModel
if (!isset($configUpdate['uploads']) || !is_array($configUpdate['uploads'])) {
$configUpdate['uploads'] = [
'resumableChunkMb' => 1.5,
'resumableTtlHours' => 6.0,
];
} else {
$raw = $configUpdate['uploads']['resumableChunkMb'] ?? 1.5;
$num = is_numeric($raw) ? (float)$raw : 1.5;
$configUpdate['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
$rawTtl = $configUpdate['uploads']['resumableTtlHours'] ?? 6.0;
$numTtl = is_numeric($rawTtl) ? (float)$rawTtl : 6.0;
$configUpdate['uploads']['resumableTtlHours'] = max(0.5, min(168, $numTtl));
}
// ---- ClamAV (upload scan toggle + exclude list) ----
@@ -878,11 +886,15 @@ class AdminModel
if (!isset($config['uploads']) || !is_array($config['uploads'])) {
$config['uploads'] = [
'resumableChunkMb' => 1.5,
'resumableTtlHours' => 6.0,
];
} else {
$raw = $config['uploads']['resumableChunkMb'] ?? 1.5;
$num = is_numeric($raw) ? (float)$raw : 1.5;
$config['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
$rawTtl = $config['uploads']['resumableTtlHours'] ?? 6.0;
$numTtl = is_numeric($rawTtl) ? (float)$rawTtl : 6.0;
$config['uploads']['resumableTtlHours'] = max(0.5, min(168, $numTtl));
}
// ---- Ensure ONLYOFFICE structure exists, sanitize values ----
@@ -1099,6 +1111,7 @@ class AdminModel
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)),
'uploads' => [
'resumableChunkMb' => 1.5,
'resumableTtlHours' => 6.0,
],
'onlyoffice' => [
'enabled' => false,
+5
View File
@@ -9,6 +9,7 @@ require_once PROJECT_ROOT . '/src/lib/CryptoAtRest.php';
require_once PROJECT_ROOT . '/src/lib/StorageRegistry.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/models/FileModel.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class FolderModel
{
@@ -1560,6 +1561,10 @@ class FolderModel
if ($err) return ["error" => $err];
$storage = self::storage();
try {
UploadModel::cleanupResumableForFolder($relative);
} catch (\Throwable $e) { /* ignore */ }
// Prevent deletion if not empty.
if ($storage->isLocal()) {
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
+389 -7
View File
@@ -16,6 +16,9 @@ class UploadModel
* Log file for virus detections (JSONL; one JSON record per line).
*/
private const VIRUS_LOG_MAX_BYTES = 5242880; // 5 MB soft rotation
private const RESUMABLE_INDEX_FILE = 'resumable_pending.json';
private const RESUMABLE_SWEEP_INTERVAL = 1800;
private const RESUMABLE_INDEX_UPDATE_MIN = 120;
private static function uploadRoot(): string
{
@@ -56,6 +59,263 @@ class UploadModel
return $base;
}
private static function resumableTtlSeconds(): int
{
$hours = null;
$env = getenv('FR_RESUMABLE_TTL_HOURS');
if ($env !== false && trim((string)$env) !== '') {
$hours = (float)$env;
} elseif (defined('FR_RESUMABLE_TTL_HOURS')) {
$hours = (float)FR_RESUMABLE_TTL_HOURS;
} elseif (class_exists('AdminModel')) {
$cfg = AdminModel::getConfig();
if (is_array($cfg) && !isset($cfg['error'])) {
$raw = $cfg['uploads']['resumableTtlHours'] ?? null;
if (is_numeric($raw)) {
$hours = (float)$raw;
}
}
}
if ($hours === null || $hours <= 0) {
$hours = 6.0;
}
$hours = max(0.5, min(168, $hours));
return (int)round($hours * 3600);
}
private static function resumableIndexPath(): string
{
$base = rtrim(self::metaRoot(), '/\\') . DIRECTORY_SEPARATOR;
if (!is_dir($base)) {
@mkdir($base, 0775, true);
}
return $base . self::RESUMABLE_INDEX_FILE;
}
private static function resumableFolderKey(string $folderSan): string
{
$folderSan = trim(str_replace('\\', '/', $folderSan), '/');
if ($folderSan === '' || $folderSan === 'root') {
return 'root';
}
return $folderSan;
}
private static function loadResumableIndex(): array
{
$path = self::resumableIndexPath();
if (!is_file($path)) {
return ['lastSweep' => 0, 'folders' => []];
}
$raw = @file_get_contents($path);
$data = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($data)) {
return ['lastSweep' => 0, 'folders' => []];
}
$folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
$lastSweep = isset($data['lastSweep']) ? (int)$data['lastSweep'] : 0;
return ['lastSweep' => $lastSweep, 'folders' => $folders];
}
private static function saveResumableIndex(array $data): void
{
$path = self::resumableIndexPath();
$payload = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($payload === false) {
return;
}
@file_put_contents($path, $payload, LOCK_EX);
}
private static function markResumablePending(string $folderSan): void
{
$folderKey = self::resumableFolderKey($folderSan);
$now = time();
$data = self::loadResumableIndex();
$folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
$lastSeen = isset($folders[$folderKey]) ? (int)$folders[$folderKey] : 0;
if ($lastSeen !== 0 && ($now - $lastSeen) < self::RESUMABLE_INDEX_UPDATE_MIN) {
return;
}
$folders[$folderKey] = $now;
$data['folders'] = $folders;
if (!isset($data['lastSweep'])) {
$data['lastSweep'] = 0;
}
self::saveResumableIndex($data);
}
private static function cleanupResumableTempDirs(string $folderSan, bool $isLocal, bool $remove = true): bool
{
$baseDir = self::stagingRoot($isLocal);
if ($folderSan !== '') {
$baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
$baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR;
if (!is_dir($baseDir)) {
return false;
}
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
$entries = @scandir($baseDir);
if (!is_array($entries)) {
return false;
}
$found = false;
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
if (!preg_match($regex, $name)) {
continue;
}
$found = true;
if ($remove) {
self::rrmdir($baseDir . $name);
}
}
if (!$remove) {
return $found;
}
$entries = @scandir($baseDir);
if (!is_array($entries)) {
return false;
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
if (preg_match($regex, $name)) {
return true;
}
}
return false;
}
private static function maybeSweepResumableExpired(bool $isLocal): void
{
self::sweepResumableExpiredInternal($isLocal, false);
}
public static function sweepResumableExpired(bool $force = true): array
{
$isLocal = self::isLocalSourceType();
return self::sweepResumableExpiredInternal($isLocal, $force);
}
/**
* Purge all resumable temp folders tracked in the index, ignoring TTL.
*/
public static function purgeResumableAll(): array
{
$isLocal = self::isLocalSourceType();
return self::purgeResumableAllInternal($isLocal);
}
private static function sweepResumableExpiredInternal(bool $isLocal, bool $force): array
{
$ttl = self::resumableTtlSeconds();
if ($ttl <= 0) {
return ['checked' => 0, 'removed' => 0, 'remaining' => 0, 'skipped' => true];
}
$data = self::loadResumableIndex();
$folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
if (!$folders) {
$data['lastSweep'] = time();
self::saveResumableIndex($data);
return ['checked' => 0, 'removed' => 0, 'remaining' => 0, 'skipped' => false];
}
$now = time();
$lastSweep = isset($data['lastSweep']) ? (int)$data['lastSweep'] : 0;
if (!$force && $lastSweep !== 0 && ($now - $lastSweep) < self::RESUMABLE_SWEEP_INTERVAL) {
return ['checked' => 0, 'removed' => 0, 'remaining' => count($folders), 'skipped' => true];
}
$checked = 0;
$removed = 0;
$remaining = 0;
foreach ($folders as $folderKey => $lastSeenRaw) {
$lastSeen = (int)$lastSeenRaw;
if ($lastSeen <= 0) {
unset($folders[$folderKey]);
continue;
}
$checked++;
if (($now - $lastSeen) < $ttl) {
$remaining++;
continue;
}
$folderSan = ($folderKey === 'root') ? '' : (string)$folderKey;
$hasRemaining = self::cleanupResumableTempDirs($folderSan, $isLocal, true);
if (!$hasRemaining) {
unset($folders[$folderKey]);
$removed++;
} else {
$remaining++;
}
}
$data['folders'] = $folders;
$data['lastSweep'] = $now;
self::saveResumableIndex($data);
return ['checked' => $checked, 'removed' => $removed, 'remaining' => $remaining, 'skipped' => false];
}
private static function purgeResumableAllInternal(bool $isLocal): array
{
$data = self::loadResumableIndex();
$folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
$now = time();
if (!$folders) {
$data['lastSweep'] = $now;
self::saveResumableIndex($data);
return ['checked' => 0, 'removed' => 0, 'remaining' => 0, 'skipped' => false];
}
$checked = 0;
$removed = 0;
$remaining = 0;
foreach ($folders as $folderKey => $lastSeenRaw) {
$lastSeen = (int)$lastSeenRaw;
if ($lastSeen <= 0) {
unset($folders[$folderKey]);
continue;
}
$checked++;
$folderSan = ($folderKey === 'root') ? '' : (string)$folderKey;
$hasRemaining = self::cleanupResumableTempDirs($folderSan, $isLocal, true);
if (!$hasRemaining) {
unset($folders[$folderKey]);
$removed++;
} else {
$remaining++;
}
}
$data['folders'] = $folders;
$data['lastSweep'] = $now;
self::saveResumableIndex($data);
return ['checked' => $checked, 'removed' => $removed, 'remaining' => $remaining, 'skipped' => false];
}
private static function buildStorageDir(string $folderSan, string $relativeSubDir): string
{
$base = rtrim(self::uploadRoot(), '/\\');
@@ -412,6 +672,8 @@ class UploadModel
}
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
self::markResumablePending($folderSan);
self::maybeSweepResumableExpired($isLocal);
if (empty($files['file']) || !isset($files['file']['name'])) {
return ['error' => 'No files received'];
@@ -959,17 +1221,137 @@ class UploadModel
. str_replace('/', DIRECTORY_SEPARATOR, $targetFolder) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseDir . $folder;
if (!is_dir($tempDir)) {
return ['success' => true, 'message' => 'Temporary folder already removed.'];
$hadDir = is_dir($tempDir);
if ($hadDir) {
self::rrmdir($tempDir);
}
if (is_dir($tempDir)) {
return ['error' => 'Failed to remove temporary folder.'];
}
self::rrmdir($tempDir);
if (!is_dir($tempDir)) {
return ['success' => true, 'message' => 'Temporary folder removed.'];
$hasRemaining = self::cleanupResumableTempDirs($targetFolder, $isLocal, false);
if (!$hasRemaining) {
$data = self::loadResumableIndex();
$folderKey = self::resumableFolderKey($targetFolder);
if (isset($data['folders'][$folderKey])) {
unset($data['folders'][$folderKey]);
self::saveResumableIndex($data);
}
}
return ['error' => 'Failed to remove temporary folder.'];
return [
'success' => true,
'message' => $hadDir ? 'Temporary folder removed.' : 'Temporary folder already removed.',
];
}
/**
* Force-clean any resumable_* temp folders for a target folder.
*/
public static function cleanupResumableForFolder(string $folder): void
{
$raw = trim($folder);
$folderSan = self::sanitizeFolder($raw === '' ? 'root' : $raw);
if ($folderSan === '' && $raw !== '' && strtolower($raw) !== 'root') {
return;
}
$isLocal = self::isLocalSourceType();
$hasRemaining = self::cleanupResumableTempDirs($folderSan, $isLocal, true);
if ($hasRemaining) {
return;
}
$data = self::loadResumableIndex();
$folderKey = self::resumableFolderKey($folderSan);
if (isset($data['folders'][$folderKey])) {
unset($data['folders'][$folderKey]);
self::saveResumableIndex($data);
}
}
/**
* Check which files already exist in the target folder.
*
* @param string $folder Normalized folder (e.g., "root" or "team/reports").
* @param array $files Array of ['path' => 'sub/file.txt', 'size' => 123].
* @return array
*/
public static function checkExisting(string $folder, array $files): array
{
$storage = StorageRegistry::getAdapter();
$folderSan = self::sanitizeFolder($folder);
$existing = [];
$seen = [];
foreach ($files as $entry) {
if (!is_array($entry)) {
continue;
}
$rawPath = isset($entry['path']) ? (string)$entry['path'] : '';
if ($rawPath === '') {
continue;
}
$path = str_replace('\\', '/', trim($rawPath));
$path = ltrim($path, '/');
if ($path === '') {
continue;
}
if (isset($seen[$path])) {
continue;
}
$seen[$path] = true;
[$subDir, $fileName] = self::parseRelativePath($path);
if ($subDir === null || $fileName === '') {
continue;
}
$storagePath = self::buildStoragePath($folderSan, $subDir, $fileName);
$exists = false;
$existingSize = null;
if ($storage->isLocal()) {
if (is_file($storagePath)) {
$exists = true;
$size = @filesize($storagePath);
$existingSize = ($size === false) ? null : (int)$size;
}
} else {
try {
$stat = $storage->stat($storagePath);
if ($stat && ($stat['type'] ?? '') === 'file') {
$exists = true;
$size = $stat['size'] ?? null;
$existingSize = ($size === null) ? null : (int)$size;
}
} catch (\Throwable $e) {
// Best-effort: treat as non-existing on stat errors.
}
}
if (!$exists) {
continue;
}
$reqSize = null;
if (array_key_exists('size', $entry) && is_numeric($entry['size'])) {
$reqSize = (int)$entry['size'];
}
$sameSize = null;
if ($reqSize !== null && $existingSize !== null) {
$sameSize = ($reqSize === $existingSize);
}
$existing[] = [
'path' => ($subDir !== '' ? $subDir . '/' : '') . $fileName,
'size' => $existingSize,
'sameSize' => $sameSize,
];
}
return ['existing' => $existing];
}
/**
+15 -1
View File
@@ -120,12 +120,19 @@ use OpenApi\Annotations as OA;
* @OA\Schema(
* schema="AdminGetConfigPublic",
* type="object",
* required={"header_title","loginOptions","globalOtpauthUrl","enableWebDAV","sharedMaxUploadSize","oidc"},
* required={"header_title","loginOptions","globalOtpauthUrl","enableWebDAV","sharedMaxUploadSize","uploads","oidc"},
* @OA\Property(property="header_title", type="string", example="FileRise"),
* @OA\Property(property="loginOptions", ref="#/components/schemas/LoginOptionsPublic"),
* @OA\Property(property="globalOtpauthUrl", type="string"),
* @OA\Property(property="enableWebDAV", type="boolean"),
* @OA\Property(property="sharedMaxUploadSize", type="integer", format="int64"),
* @OA\Property(
* property="uploads",
* type="object",
* additionalProperties=false,
* @OA\Property(property="resumableChunkMb", type="number", format="float", minimum=0.5, maximum=100, example=1.5),
* @OA\Property(property="resumableTtlHours", type="number", format="float", minimum=0.5, maximum=168, example=6)
* ),
* @OA\Property(property="oidc", ref="#/components/schemas/OIDCConfigPublic")
* ),
* @OA\Schema(
@@ -170,6 +177,13 @@ use OpenApi\Annotations as OA;
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
* @OA\Property(property="sharedMaxUploadSize", type="integer", format="int64", minimum=0, example=52428800),
* @OA\Property(
* property="uploads",
* type="object",
* additionalProperties=false,
* @OA\Property(property="resumableChunkMb", type="number", format="float", minimum=0.5, maximum=100, example=1.5),
* @OA\Property(property="resumableTtlHours", type="number", format="float", minimum=0.5, maximum=168, example=6)
* ),
* @OA\Property(
* property="oidc",
* type="object",
* additionalProperties=false,