mirror of
https://github.com/error311/FileRise.git
synced 2026-05-12 06:50:54 -05:00
release(v3.2.3): resumable upload UX fixes + stale chunk cleanup + folder re-upload conflict handling (closes #100, closes #101, closes #102)
- uploads: fix resumable resume banner layout for long filenames + improve dismiss behavior - uploads: add preflight check existing files flow for folder uploads (resume+skip+overwrite) - cleanup: add resumable TTL (Admin + env) + background sweeps + admin CLI cleanup tools - folders: allow deleting empty folders by cleaning resumable temp dirs first - docs: update OpenAPI (uploads config, checkExisting endpoint, cleanup endpoint)
This commit is contained in:
+51
-1
@@ -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`
|
||||
|
||||
|
||||
+155
-2
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
// public/api/admin/resumableCleanup.php
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/admin/resumableCleanup.php",
|
||||
* summary="Run resumable upload cleanup sweep",
|
||||
* description="Deletes expired resumable upload temp folders using the configured TTL.",
|
||||
* operationId="adminResumableCleanup",
|
||||
* tags={"Admin"},
|
||||
* security={{"cookieAuth": {}, "CsrfHeader": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=false,
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="all", type="boolean", example=true, description="Sweep all sources when supported"),
|
||||
* @OA\Property(property="purgeAll", type="boolean", example=true, description="Remove all resumable temp folders, ignoring TTL"),
|
||||
* @OA\Property(property="sourceId", type="string", example="local", description="Optional source id to sweep")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Cleanup results",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="checked", type="integer", example=12),
|
||||
* @OA\Property(property="removed", type="integer", example=3),
|
||||
* @OA\Property(property="remaining", type="integer", example=2),
|
||||
* @OA\Property(property="sources", type="integer", example=1)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Bad request"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=405, description="Method not allowed"),
|
||||
* @OA\Response(response=500, description="Server error")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
AdminController::resumableCleanup();
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// public/api/upload/checkExisting.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/upload/checkExisting.php",
|
||||
* summary="Check for existing files before upload",
|
||||
* description="Checks whether the provided relative paths already exist in the target folder.",
|
||||
* operationId="checkUploadExisting",
|
||||
* tags={"Uploads"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\MediaType(
|
||||
* mediaType="application/json",
|
||||
* @OA\Schema(
|
||||
* required={"folder","files"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="sourceId", type="string", example="local"),
|
||||
* @OA\Property(
|
||||
* property="files",
|
||||
* type="array",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* required={"path"},
|
||||
* @OA\Property(property="path", type="string", example="team/reports/report.pdf"),
|
||||
* @OA\Property(property="size", type="integer", format="int64", example=123456)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Existing files",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(
|
||||
* property="existing",
|
||||
* type="array",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* @OA\Property(property="path", type="string"),
|
||||
* @OA\Property(property="size", type="integer", format="int64", nullable=true),
|
||||
* @OA\Property(property="sameSize", type="boolean", nullable=true)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Invalid CSRF token")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||
|
||||
$uploadController = new UploadController();
|
||||
$uploadController->checkExisting();
|
||||
+19
-3
@@ -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;
|
||||
|
||||
@@ -644,6 +644,71 @@ function wireClamavTestButton(scope = document) {
|
||||
});
|
||||
}
|
||||
|
||||
function wireResumableCleanupButton(scope = document) {
|
||||
const btn = scope.querySelector('#resumableCleanupNowBtn');
|
||||
const statusEl = scope.querySelector('#resumableCleanupStatus');
|
||||
if (!btn || !statusEl || btn.__wired) return;
|
||||
|
||||
btn.__wired = true;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
statusEl.textContent = t('resumable_cleanup_running') || 'Running cleanup...';
|
||||
statusEl.className = 'small text-muted';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/resumableCleanup.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify({ all: true, purgeAll: true })
|
||||
});
|
||||
|
||||
const data = await safeJson(res);
|
||||
|
||||
if (!data || data.success !== true) {
|
||||
const msg =
|
||||
(data && (data.error || data.message)) ||
|
||||
t('resumable_cleanup_failed') ||
|
||||
'Resumable cleanup failed.';
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'small text-danger';
|
||||
showToast(msg, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const checked = parseInt(data.checked || 0, 10) || 0;
|
||||
const removed = parseInt(data.removed || 0, 10) || 0;
|
||||
const remaining = parseInt(data.remaining || 0, 10) || 0;
|
||||
const sources = parseInt(data.sources || 1, 10) || 1;
|
||||
|
||||
const msg = sources > 1
|
||||
? (t('resumable_cleanup_done_sources', { removed, remaining, checked, sources })
|
||||
|| `Cleanup complete: removed ${removed}, remaining ${remaining}, checked ${checked} across ${sources} sources.`)
|
||||
: (t('resumable_cleanup_done', { removed, remaining, checked })
|
||||
|| `Cleanup complete: removed ${removed}, remaining ${remaining}, checked ${checked}.`);
|
||||
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'small text-success';
|
||||
showToast(msg, 'success');
|
||||
} catch (e) {
|
||||
console.error('Resumable cleanup error', e);
|
||||
const msg =
|
||||
(e && e.message ? e.message : '') ||
|
||||
t('resumable_cleanup_failed') ||
|
||||
'Resumable cleanup failed.';
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'small text-danger';
|
||||
showToast(msg, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireIgnoreRegexPresetButton(scope = document) {
|
||||
const btn = scope.querySelector('#ignoreRegexSnapshotsPreset');
|
||||
const input = scope.querySelector('#ignoreRegex');
|
||||
@@ -2534,6 +2599,7 @@ function captureInitialAdminConfig() {
|
||||
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
|
||||
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
|
||||
resumableChunkMb: (document.getElementById("resumableChunkMb")?.value || "").trim(),
|
||||
resumableTtlHours: (document.getElementById("resumableTtlHours")?.value || "").trim(),
|
||||
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(),
|
||||
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
||||
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
||||
@@ -2595,6 +2661,7 @@ function hasUnsavedChanges() {
|
||||
getChk("enableWebDAV") !== o.enableWebDAV ||
|
||||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
|
||||
getVal("resumableChunkMb") !== o.resumableChunkMb ||
|
||||
getVal("resumableTtlHours") !== o.resumableTtlHours ||
|
||||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
|
||||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
|
||||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
|
||||
@@ -5112,6 +5179,59 @@ export function openAdminPanel() {
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:10px;">
|
||||
<label for="resumableTtlHours">
|
||||
${tf("resumable_cleanup_hours_label", "Resumable cleanup age (hours)")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="resumableTtlHours"
|
||||
class="form-control"
|
||||
min="0.5"
|
||||
max="168"
|
||||
step="0.5"
|
||||
/>
|
||||
<small class="text-muted d-block mt-1">
|
||||
${tf(
|
||||
"resumable_cleanup_hours_help",
|
||||
"Deletes unfinished resumable uploads after this age. Applies to background sweeps and manual cleanup."
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
id="resumableCleanupNowBtn"
|
||||
class="btn btn-sm btn-secondary">
|
||||
${tf("resumable_cleanup_run_now", "Run cleanup now")}
|
||||
</button>
|
||||
<small class="text-muted d-block" style="margin-top:4px;">
|
||||
${tf(
|
||||
"resumable_cleanup_run_now_help",
|
||||
"Immediately sweeps expired resumable temp folders using the age above."
|
||||
)}
|
||||
</small>
|
||||
<div id="resumableCleanupStatus" class="small text-muted" style="margin-top:4px;"></div>
|
||||
<div class="small text-muted" style="margin-top:6px;">
|
||||
<div style="font-weight:600;">
|
||||
${tf("resumable_cleanup_cron_title", "Cron example")}
|
||||
</div>
|
||||
<div>
|
||||
${tf(
|
||||
"resumable_cleanup_cron_help",
|
||||
"Example hourly job: <code>0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval</code>"
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${tf(
|
||||
"resumable_cleanup_cron_note",
|
||||
"Replace /path/to/FileRise with your install path."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="admin-divider">
|
||||
|
||||
<div class="admin-subsection-title" style="margin-top:2px;">
|
||||
@@ -5300,6 +5420,7 @@ export function openAdminPanel() {
|
||||
const headerSettingsScope = document.getElementById("headerSettingsContent");
|
||||
wireIgnoreRegexPresetButton(headerSettingsScope);
|
||||
wireClamavTestButton(uploadScope);
|
||||
wireResumableCleanupButton(uploadScope);
|
||||
initVirusLogUI({ isPro });
|
||||
// ONLYOFFICE section (moved into adminOnlyOffice.js)
|
||||
initOnlyOfficeUI({ config });
|
||||
@@ -6450,6 +6571,13 @@ ${t("shared_max_upload_size_bytes")}
|
||||
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
|
||||
chunkEl.value = val;
|
||||
}
|
||||
const ttlEl = document.getElementById("resumableTtlHours");
|
||||
if (ttlEl) {
|
||||
const raw = uploadCfg.resumableTtlHours;
|
||||
const num = parseFloat(raw);
|
||||
const val = Number.isFinite(num) ? Math.min(168, Math.max(0.5, num)) : 6;
|
||||
ttlEl.value = val;
|
||||
}
|
||||
|
||||
// Published URL (optional)
|
||||
const deploy = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
|
||||
@@ -6549,6 +6677,13 @@ ${t("shared_max_upload_size_bytes")}
|
||||
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
|
||||
chunkEl2.value = val;
|
||||
}
|
||||
const ttlEl2 = document.getElementById("resumableTtlHours");
|
||||
if (ttlEl2) {
|
||||
const raw = uploadCfg2.resumableTtlHours;
|
||||
const num = parseFloat(raw);
|
||||
const val = Number.isFinite(num) ? Math.min(168, Math.max(0.5, num)) : 6;
|
||||
ttlEl2.value = val;
|
||||
}
|
||||
|
||||
// Published URL (optional)
|
||||
const deploy2 = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
|
||||
@@ -6634,6 +6769,7 @@ ${t("shared_max_upload_size_bytes")}
|
||||
const headerSettingsScope = document.getElementById("headerSettingsContent");
|
||||
wireIgnoreRegexPresetButton(headerSettingsScope);
|
||||
wireClamavTestButton(uploadScope);
|
||||
wireResumableCleanupButton(uploadScope);
|
||||
initVirusLogUI({ isPro });
|
||||
renderAdminEncryptionSection({ config, dark });
|
||||
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
|
||||
@@ -6838,6 +6974,13 @@ function handleSave() {
|
||||
parseFloat(document.getElementById("resumableChunkMb")?.value || "1.5") || 1.5
|
||||
)
|
||||
),
|
||||
resumableTtlHours: Math.max(
|
||||
0.5,
|
||||
Math.min(
|
||||
168,
|
||||
parseFloat(document.getElementById("resumableTtlHours")?.value || "6") || 6
|
||||
)
|
||||
),
|
||||
},
|
||||
clamav: {
|
||||
scanUploads: document.getElementById("clamavScanUploads").checked,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -57,6 +57,24 @@ const translations = {
|
||||
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
||||
"no_files_selected_default": "No files selected",
|
||||
"choose_files": "Choose files",
|
||||
"upload_conflict_title": "Existing files detected",
|
||||
"upload_conflict_message": "Found {existing} of {total} files already in this folder ({same} same size, {diff} different size). Resume skips same-size files, Skip ignores all existing files, Overwrite reuploads everything.",
|
||||
"upload_conflict_resume": "Resume",
|
||||
"upload_conflict_skip": "Skip existing",
|
||||
"upload_conflict_overwrite": "Overwrite",
|
||||
"upload_conflict_all_skipped": "All selected files already exist.",
|
||||
"upload_conflict_skipped": "Skipped {count} existing file(s).",
|
||||
"resumable_cleanup_hours_label": "Resumable cleanup age (hours)",
|
||||
"resumable_cleanup_hours_help": "Deletes unfinished resumable uploads after this age. Applies to background sweeps and manual cleanup.",
|
||||
"resumable_cleanup_run_now": "Run cleanup now",
|
||||
"resumable_cleanup_run_now_help": "Immediately clears resumable temp folders, ignoring the age above.",
|
||||
"resumable_cleanup_running": "Running cleanup...",
|
||||
"resumable_cleanup_done": "Cleanup complete: removed {removed}, remaining {remaining}, checked {checked}.",
|
||||
"resumable_cleanup_done_sources": "Cleanup complete: removed {removed}, remaining {remaining}, checked {checked} across {sources} sources.",
|
||||
"resumable_cleanup_failed": "Resumable cleanup failed.",
|
||||
"resumable_cleanup_cron_title": "Cron example",
|
||||
"resumable_cleanup_cron_help": "Example hourly job: <code>0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval</code>",
|
||||
"resumable_cleanup_cron_note": "Replace /path/to/FileRise with your install path.",
|
||||
"delete_selected": "Delete Selected",
|
||||
"copy_selected": "Copy Selected",
|
||||
"move_selected": "Move Selected",
|
||||
|
||||
+263
-5
@@ -8,6 +8,7 @@ import { withBase } from './basePath.js?v={{APP_QVER}}';
|
||||
|
||||
const UPLOAD_URL = withBase('/api/upload/upload.php');
|
||||
const RESUMABLE_TARGET = UPLOAD_URL;
|
||||
const CHECK_EXISTING_URL = withBase('/api/upload/checkExisting.php');
|
||||
|
||||
function getActiveUploadSourceId() {
|
||||
const paneKey = window.activePane === 'secondary' ? 'secondary' : 'primary';
|
||||
@@ -385,6 +386,14 @@ function showResumableDraftBanner() {
|
||||
count === 1
|
||||
? 'You have a partially uploaded file'
|
||||
: `You have ${count} partially uploaded files. Latest:`;
|
||||
const resumeHint =
|
||||
count === 1
|
||||
? 'Choose it again from your device to resume.'
|
||||
: 'Choose them again from your device to resume.';
|
||||
const dismissHint = 'Dismiss clears the partial uploads and temporary files.';
|
||||
const cleanupIds = candidates
|
||||
.map(entry => entry && entry.identifier)
|
||||
.filter(Boolean);
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'resumableDraftBanner';
|
||||
@@ -394,9 +403,10 @@ function showResumableDraftBanner() {
|
||||
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
|
||||
<span class="upload-resume-text">
|
||||
${countText}
|
||||
<strong>${escapeHTML(latest.fileName)}</strong>
|
||||
<strong class="upload-resume-name">${escapeHTML(latest.fileName)}</strong>
|
||||
(~${latest.lastPercent}%).
|
||||
Choose it again from your device to resume.
|
||||
${resumeHint}
|
||||
${dismissHint}
|
||||
</span>
|
||||
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
|
||||
</div>
|
||||
@@ -407,6 +417,11 @@ function showResumableDraftBanner() {
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
// Clear all resumable hints for this folder when the user dismisses.
|
||||
clearResumableDraftsForFolder(folder);
|
||||
if (window.csrfToken && cleanupIds.length) {
|
||||
cleanupIds.forEach(identifier => {
|
||||
removeChunkFolderRepeatedly(identifier, window.csrfToken, folder, 2, 800);
|
||||
});
|
||||
}
|
||||
if (banner.parentNode) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
}
|
||||
@@ -592,9 +607,238 @@ function applyResumableRelativePath(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUploadPath(raw) {
|
||||
return String(raw || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getUploadPathForFile(file) {
|
||||
if (!file || typeof file !== 'object') return '';
|
||||
const raw =
|
||||
file.customRelativePath ||
|
||||
file.relativePath ||
|
||||
file.webkitRelativePath ||
|
||||
file.name ||
|
||||
file.fileName ||
|
||||
'';
|
||||
return normalizeUploadPath(raw);
|
||||
}
|
||||
|
||||
function ensureUploadConflictModal() {
|
||||
let modal = document.getElementById('uploadConflictModal');
|
||||
if (modal) return modal;
|
||||
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'uploadConflictModal';
|
||||
modal.className = 'modal';
|
||||
modal.style.display = 'none';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h4 id="uploadConflictTitle"></h4>
|
||||
<p id="uploadConflictMessage"></p>
|
||||
<div class="button-container" style="flex-wrap: wrap; justify-content: flex-end;">
|
||||
<button id="uploadConflictResume" class="btn btn-primary"></button>
|
||||
<button id="uploadConflictSkip" class="btn btn-secondary"></button>
|
||||
<button id="uploadConflictOverwrite" class="btn btn-danger"></button>
|
||||
<button id="uploadConflictCancel" class="btn btn-secondary"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function showUploadConflictModal(stats) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = ensureUploadConflictModal();
|
||||
const titleEl = modal.querySelector('#uploadConflictTitle');
|
||||
const msgEl = modal.querySelector('#uploadConflictMessage');
|
||||
const resumeBtn = modal.querySelector('#uploadConflictResume');
|
||||
const skipBtn = modal.querySelector('#uploadConflictSkip');
|
||||
const overwriteBtn = modal.querySelector('#uploadConflictOverwrite');
|
||||
const cancelBtn = modal.querySelector('#uploadConflictCancel');
|
||||
|
||||
const total = stats?.total || 0;
|
||||
const existing = stats?.existing || 0;
|
||||
const sameSize = stats?.sameSize || 0;
|
||||
const diffSize = stats?.diffSize || 0;
|
||||
|
||||
const titleText = t('upload_conflict_title') || 'Existing files detected';
|
||||
const msgText = t('upload_conflict_message', {
|
||||
existing,
|
||||
total,
|
||||
same: sameSize,
|
||||
diff: diffSize
|
||||
}) || `Found ${existing} of ${total} files already in this folder.`;
|
||||
|
||||
titleEl.textContent = titleText;
|
||||
msgEl.textContent = msgText;
|
||||
resumeBtn.textContent = t('upload_conflict_resume') || 'Resume';
|
||||
skipBtn.textContent = t('upload_conflict_skip') || 'Skip existing';
|
||||
overwriteBtn.textContent = t('upload_conflict_overwrite') || 'Overwrite';
|
||||
cancelBtn.textContent = t('cancel') || 'Cancel';
|
||||
|
||||
modal.style.display = 'block';
|
||||
|
||||
function cleanup(choice) {
|
||||
modal.style.display = 'none';
|
||||
resumeBtn.removeEventListener('click', onResume);
|
||||
skipBtn.removeEventListener('click', onSkip);
|
||||
overwriteBtn.removeEventListener('click', onOverwrite);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
resolve(choice);
|
||||
}
|
||||
|
||||
function onResume() { cleanup('resume'); }
|
||||
function onSkip() { cleanup('skip'); }
|
||||
function onOverwrite() { cleanup('overwrite'); }
|
||||
function onCancel() { cleanup('cancel'); }
|
||||
|
||||
resumeBtn.addEventListener('click', onResume);
|
||||
skipBtn.addEventListener('click', onSkip);
|
||||
overwriteBtn.addEventListener('click', onOverwrite);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchExistingUploads(payload, retry = true) {
|
||||
try {
|
||||
const res = await fetch(CHECK_EXISTING_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (data && data.csrf_expired && data.csrf_token && retry) {
|
||||
window.csrfToken = data.csrf_token;
|
||||
return fetchExistingUploads(payload, false);
|
||||
}
|
||||
if (!res.ok || !data || typeof data !== 'object' || data.error) {
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.warn('Upload existence check failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function filterExistingUploads(files) {
|
||||
const result = { files, autoStart: false };
|
||||
if (!Array.isArray(files) || files.length === 0) return result;
|
||||
if (!hasFolderPaths(files)) return result;
|
||||
|
||||
const payloadFiles = [];
|
||||
files.forEach(file => {
|
||||
const path = getUploadPathForFile(file);
|
||||
if (!path) return;
|
||||
const size = typeof file.size === 'number'
|
||||
? file.size
|
||||
: (file.file && typeof file.file.size === 'number' ? file.file.size : null);
|
||||
payloadFiles.push({ path, size });
|
||||
});
|
||||
|
||||
if (!payloadFiles.length) return result;
|
||||
|
||||
const payload = {
|
||||
folder: window.currentFolder || 'root',
|
||||
files: payloadFiles
|
||||
};
|
||||
const sourceId = getActiveUploadSourceId();
|
||||
if (sourceId) payload.sourceId = sourceId;
|
||||
|
||||
const existingResult = await fetchExistingUploads(payload);
|
||||
if (!existingResult || !Array.isArray(existingResult.existing) || existingResult.existing.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const existing = existingResult.existing;
|
||||
const existingCount = existing.length;
|
||||
const sameCount = existing.filter(e => e && e.sameSize === true).length;
|
||||
const diffCount = existingCount - sameCount;
|
||||
|
||||
const choice = await showUploadConflictModal({
|
||||
total: payloadFiles.length,
|
||||
existing: existingCount,
|
||||
sameSize: sameCount,
|
||||
diffSize: diffCount
|
||||
});
|
||||
|
||||
if (choice === 'cancel') return { files: [], autoStart: false };
|
||||
if (choice === 'overwrite') return { files, autoStart: true };
|
||||
|
||||
const existingAny = new Set(existing.map(e => normalizeUploadPath(e.path)));
|
||||
const existingSame = new Set(
|
||||
existing.filter(e => e && e.sameSize === true).map(e => normalizeUploadPath(e.path))
|
||||
);
|
||||
|
||||
const filtered = files.filter(file => {
|
||||
const path = getUploadPathForFile(file);
|
||||
if (!path) return true;
|
||||
if (choice === 'skip') {
|
||||
return !existingAny.has(path);
|
||||
}
|
||||
return !existingSame.has(path);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
showToast(t('upload_conflict_all_skipped') || 'All selected files already exist.', 'info');
|
||||
return { files: filtered, autoStart: false };
|
||||
}
|
||||
|
||||
const skipped = files.length - filtered.length;
|
||||
if (skipped > 0) {
|
||||
const msg = t('upload_conflict_skipped', { count: skipped }) || `Skipped ${skipped} existing file(s).`;
|
||||
showToast(msg, 'info');
|
||||
}
|
||||
|
||||
return { files: filtered, autoStart: true };
|
||||
}
|
||||
|
||||
function startResumableUploadNow() {
|
||||
if (!resumableInstance) return;
|
||||
if (!Array.isArray(resumableInstance.files) || resumableInstance.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (typeof resumableInstance.isUploading === 'function' && resumableInstance.isUploading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadButtonVisible(false);
|
||||
showVirusScanNotice();
|
||||
resumableInstance.opts.headers = resumableInstance.opts.headers || {};
|
||||
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||
if (typeof resumableInstance.opts.query !== 'function') {
|
||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||
const sourceId = getActiveUploadSourceId();
|
||||
if (sourceId) {
|
||||
resumableInstance.opts.query.sourceId = sourceId;
|
||||
} else {
|
||||
delete resumableInstance.opts.query.sourceId;
|
||||
}
|
||||
}
|
||||
resumableInstance.upload();
|
||||
showToast(t('upload_resumable_started') || 'Resumable upload started...', 'info');
|
||||
}
|
||||
|
||||
async function queueResumableFiles(files) {
|
||||
const filteredResult = await filterExistingUploads(files);
|
||||
const filtered = filteredResult && Array.isArray(filteredResult.files)
|
||||
? filteredResult.files
|
||||
: [];
|
||||
const autoStart = !!filteredResult?.autoStart;
|
||||
if (!filtered || filtered.length === 0) return;
|
||||
|
||||
if (!useResumable) {
|
||||
processFiles(files);
|
||||
processFiles(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -605,14 +849,27 @@ async function queueResumableFiles(files) {
|
||||
if (!_resumableReady) await initResumableUpload();
|
||||
if (!resumableInstance) {
|
||||
// If Resumable failed to load, fall back to XHR
|
||||
processFiles(files);
|
||||
processFiles(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach(file => {
|
||||
if (_autoStartResumableTimer) {
|
||||
clearTimeout(_autoStartResumableTimer);
|
||||
_autoStartResumableTimer = null;
|
||||
}
|
||||
|
||||
filtered.forEach(file => {
|
||||
applyResumableRelativePath(file);
|
||||
resumableInstance.addFile(file);
|
||||
});
|
||||
|
||||
if (autoStart && resumableInstance) {
|
||||
// Defer until chunks are bootstrapped (Resumable builds chunks async).
|
||||
_autoStartResumableTimer = setTimeout(() => {
|
||||
_autoStartResumableTimer = null;
|
||||
startResumableUploadNow();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to repeatedly call removeChunks.php
|
||||
@@ -914,6 +1171,7 @@ let resumableInstance = null;
|
||||
let _pendingPickedFiles = []; // files picked before library/instance ready
|
||||
let _resumableReady = false;
|
||||
let _currentResumableIds = new Set();
|
||||
let _autoStartResumableTimer = null;
|
||||
|
||||
// Make init async-safe; it resolves when Resumable is constructed
|
||||
async function initResumableUpload() {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// src/cli/resumable_cleanup.php
|
||||
//
|
||||
// Sweep expired resumable_* upload temp folders based on configured TTL.
|
||||
|
||||
require __DIR__ . '/../../config/config.php';
|
||||
require __DIR__ . '/../../src/models/UploadModel.php';
|
||||
require __DIR__ . '/../../src/lib/SourceContext.php';
|
||||
|
||||
$sourceId = '';
|
||||
$allSources = false;
|
||||
$force = true;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($i === 0) {
|
||||
continue;
|
||||
}
|
||||
if ($arg === '--all') {
|
||||
$allSources = true;
|
||||
continue;
|
||||
}
|
||||
if ($arg === '--respect-interval') {
|
||||
$force = false;
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($arg, '--source=')) {
|
||||
$sourceId = trim(substr($arg, strlen('--source=')));
|
||||
continue;
|
||||
}
|
||||
if ($arg === '--source' && isset($argv[$i + 1])) {
|
||||
$sourceId = trim((string)$argv[$i + 1]);
|
||||
continue;
|
||||
}
|
||||
if ($arg === '--help' || $arg === '-h') {
|
||||
$msg = "Usage: php src/cli/resumable_cleanup.php [--all] [--source <id>] [--respect-interval]\n";
|
||||
fwrite(STDOUT, $msg);
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($allSources) {
|
||||
$totals = ['checked' => 0, 'removed' => 0, 'remaining' => 0];
|
||||
$sources = SourceContext::listAllSources();
|
||||
foreach ($sources as $src) {
|
||||
$id = (string)($src['id'] ?? '');
|
||||
if ($id !== '') {
|
||||
SourceContext::setActiveId($id, false, true);
|
||||
}
|
||||
$res = UploadModel::sweepResumableExpired($force);
|
||||
$totals['checked'] += (int)($res['checked'] ?? 0);
|
||||
$totals['removed'] += (int)($res['removed'] ?? 0);
|
||||
$totals['remaining'] += (int)($res['remaining'] ?? 0);
|
||||
}
|
||||
$msg = sprintf(
|
||||
"Resumable cleanup complete (all sources): checked=%d removed=%d remaining=%d\n",
|
||||
$totals['checked'],
|
||||
$totals['removed'],
|
||||
$totals['remaining']
|
||||
);
|
||||
fwrite(STDOUT, $msg);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($sourceId !== '') {
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
|
||||
fwrite(STDERR, "Invalid source id.\n");
|
||||
exit(1);
|
||||
}
|
||||
SourceContext::setActiveId($sourceId, false, true);
|
||||
}
|
||||
|
||||
$res = UploadModel::sweepResumableExpired($force);
|
||||
$msg = sprintf(
|
||||
"Resumable cleanup complete: checked=%d removed=%d remaining=%d\n",
|
||||
(int)($res['checked'] ?? 0),
|
||||
(int)($res['removed'] ?? 0),
|
||||
(int)($res['remaining'] ?? 0)
|
||||
);
|
||||
fwrite(STDOUT, $msg);
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "Resumable cleanup error: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ require_once PROJECT_ROOT . '/src/lib/CryptoAtRest.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/StorageRegistry.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FileModel.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
||||
|
||||
class FolderModel
|
||||
{
|
||||
@@ -1560,6 +1561,10 @@ class FolderModel
|
||||
if ($err) return ["error" => $err];
|
||||
$storage = self::storage();
|
||||
|
||||
try {
|
||||
UploadModel::cleanupResumableForFolder($relative);
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
|
||||
// Prevent deletion if not empty.
|
||||
if ($storage->isLocal()) {
|
||||
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
|
||||
|
||||
+389
-7
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user