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,