diff --git a/CHANGELOG.md b/CHANGELOG.md
index 951dc6b..a9d8cde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 don’t push the button off-screen.
+- **#101:** You can now delete a folder that only contains unfinished resumable chunks (refresh → dismiss → folder looked empty but wouldn’t 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`
diff --git a/openapi.json.dist b/openapi.json.dist
index fba08b2..6af385d 100644
--- a/openapi.json.dist
+++ b/openapi.json.dist
@@ -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"
}
]
-}
\ No newline at end of file
+}
diff --git a/public/api/admin/resumableCleanup.php b/public/api/admin/resumableCleanup.php
new file mode 100644
index 0000000..19e90f5
--- /dev/null
+++ b/public/api/admin/resumableCleanup.php
@@ -0,0 +1,45 @@
+checkExisting();
diff --git a/public/css/styles.css b/public/css/styles.css
index a80910c..b6bb397 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -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;
diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js
index 13a2ff5..49ea170 100644
--- a/public/js/adminPanel.js
+++ b/public/js/adminPanel.js
@@ -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() {
+
+
+
+
+ ${tf(
+ "resumable_cleanup_hours_help",
+ "Deletes unfinished resumable uploads after this age. Applies to background sweeps and manual cleanup."
+ )}
+
+
+
+
+
+
+ ${tf(
+ "resumable_cleanup_run_now_help",
+ "Immediately sweeps expired resumable temp folders using the age above."
+ )}
+
+
+
+
+ ${tf("resumable_cleanup_cron_title", "Cron example")}
+
+
+ ${tf(
+ "resumable_cleanup_cron_help",
+ "Example hourly job: 0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval"
+ )}
+
+
+ ${tf(
+ "resumable_cleanup_cron_note",
+ "Replace /path/to/FileRise with your install path."
+ )}
+
+
+
+
@@ -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,
diff --git a/public/js/fileListView.js b/public/js/fileListView.js
index 6cb9b69..a00492c 100644
--- a/public/js/fileListView.js
+++ b/public/js/fileListView.js
@@ -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) {
diff --git a/public/js/i18n.js b/public/js/i18n.js
index 583844b..5eab535 100644
--- a/public/js/i18n.js
+++ b/public/js/i18n.js
@@ -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: 0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval",
+ "resumable_cleanup_cron_note": "Replace /path/to/FileRise with your install path.",
"delete_selected": "Delete Selected",
"copy_selected": "Copy Selected",
"move_selected": "Move Selected",
diff --git a/public/js/upload.js b/public/js/upload.js
index 71d596d..f320e89 100644
--- a/public/js/upload.js
+++ b/public/js/upload.js
@@ -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() {
cloud_upload
${countText}
- ${escapeHTML(latest.fileName)}
+ ${escapeHTML(latest.fileName)}
(~${latest.lastPercent}%).
- Choose it again from your device to resume.
+ ${resumeHint}
+ ${dismissHint}
@@ -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 = `
+
+ `;
+
+ 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() {
diff --git a/src/cli/resumable_cleanup.php b/src/cli/resumable_cleanup.php
new file mode 100644
index 0000000..27ea64d
--- /dev/null
+++ b/src/cli/resumable_cleanup.php
@@ -0,0 +1,88 @@
+#!/usr/bin/env php
+ $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 ] [--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);
+}
diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php
index c1b6168..a7f71c2 100644
--- a/src/controllers/AdminController.php
+++ b/src/controllers/AdminController.php
@@ -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
diff --git a/src/controllers/UploadController.php b/src/controllers/UploadController.php
index 98b3428..2b73cfa 100644
--- a/src/controllers/UploadController.php
+++ b/src/controllers/UploadController.php
@@ -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));
+ }
}
diff --git a/src/models/AdminModel.php b/src/models/AdminModel.php
index 185fe8e..bd84698 100644
--- a/src/models/AdminModel.php
+++ b/src/models/AdminModel.php
@@ -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,
diff --git a/src/models/FolderModel.php b/src/models/FolderModel.php
index 3e7c71f..cb43683 100644
--- a/src/models/FolderModel.php
+++ b/src/models/FolderModel.php
@@ -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('.', '..'));
diff --git a/src/models/UploadModel.php b/src/models/UploadModel.php
index 1c5458d..946f18f 100644
--- a/src/models/UploadModel.php
+++ b/src/models/UploadModel.php
@@ -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];
}
/**
diff --git a/src/openapi/Components.php b/src/openapi/Components.php
index abf733d..4812032 100644
--- a/src/openapi/Components.php
+++ b/src/openapi/Components.php
@@ -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,