release(v3.0.1): archive create/extract upgrades (7z + RAR via unar) + login focus fix (closes #82)

- add 7z archive format option for multi-file downloads (worker + download streaming)
- expand extraction to support ZIP + 7z formats via 7z, with RAR preferring unar when available
- harden archive extraction against traversal, symlinks, zip-bombs, and empty/escaped outputs
- improve archive job robustness (stale job cleanup, clearer queued/worker errors, correct MIME/filenames)
- UI: archive format selector + name normalization, better “Extract Archive” handling, i18n updates
- fix login screen focus (auto-focus username when login prompt shows)

Closes #82
This commit is contained in:
Ryan
2026-01-14 21:06:06 -05:00
committed by GitHub
parent 1ee8306deb
commit e862ed956b
19 changed files with 1522 additions and 328 deletions
+66 -2
View File
@@ -1,6 +1,70 @@
# Changelog
## Changes 1/11/2025 (V3.0.0)
## Changes 01/14/2026 (v3.0.1 Archive update + login focus)
`release(v3.0.1): archive create/extract upgrades (7z + RAR via unar) + login focus fix (closes #82)`
**Commit message**
```text
release(v3.0.1): archive create/extract upgrades (7z + RAR via unar) + login focus fix (closes #82)
- add 7z archive format option for multi-file downloads (worker + download streaming)
- expand extraction to support ZIP + 7z formats via 7z, with RAR preferring unar when available
- harden archive extraction against traversal, symlinks, zip-bombs, and empty/escaped outputs
- improve archive job robustness (stale job cleanup, clearer queued/worker errors, correct MIME/filenames)
- UI: archive format selector + name normalization, better “Extract Archive” handling, i18n updates
- fix login screen focus (auto-focus username when login prompt shows)
Closes #82
```
**Added**
- **Archive download format selector (ZIP / 7z)** in the “Download Selected Files as Archive” modal.
- **7z archive creation** support in the background worker (`zip_worker.php`) using `7zz/7z`.
- **RAR extraction prefers `unar`** when available (FOSS-friendly); falls back to `7z` when needed.
- **Archive detection helper** `isArchiveFileName()` supporting:
- `.zip`, `.7z`, `.tar.*`, `.gz`, `.bz2`, `.xz`, `.rar`
- RAR split parts like `.r01`, `.r02`, etc.
**Changed**
- **“ZIP” language → “Archive” language** across UI, admin notes, and translations.
- **Archive job enqueue + download endpoint** now supports a `format` field (`zip` or `7z`):
- download streaming sets correct extension + MIME type (`application/zip` or `application/x-7z-compressed`)
- filename normalization strips any existing `.zip/.7z` and applies the chosen extension
- **Archive extraction** is no longer ZIP-only:
- ZIP still uses `ZipArchive`
- non-ZIP formats use `7z` listing (`7z l -slt`) + extraction of an allow-listed set
- RAR parts like `.r01` map to their base `.rar` / `.part1.rar` automatically
- **Archive queue robustness**
- stale queued/working jobs are cleaned up (PID checks + cmdline sanity where available)
- queued jobs that never start can surface a clearer error message (“worker did not start…”)
**Fixed**
- **Login UX:** auto-focus username field when the login prompt appears (reduces “why cant I type?” friction).
- **Extract action visibility:** Extract button/menu now appears for supported archive formats (not just `.zip`).
- **Better extraction feedback:** extraction API returns optional `warning` text; UI shows success + warning separately when partial issues occur.
**Security / Hardening**
- **Archive extraction safety controls**:
- blocks absolute paths / traversal (`../`) and unsupported folder names
- skips dotfiles (configurable) instead of extracting hidden entries by default
- detects and skips symlinks and removes any symlinks created during extraction
- zip-bomb limits: max uncompressed bytes + max files (configurable)
- prunes empty outputs that indicate partial/broken extraction and removes any files that escape the extraction root
**Docker**
- Image now installs **7zip + unar** so archive create/extract works out-of-the-box with FOSS tooling.
- Ubuntu repo components are restricted to **`main universe`** (avoids non-free repos by default).
---
## Changes 1/11/2026 (V3.0.0)
`release(v3.0.0): storage adapter seam + source-aware core (Sources-ready)`
@@ -131,7 +195,7 @@ FileRise v3.0.0 is a major internal milestone: a new storage adapter seam + sour
---
## Changes 1/2/2025 (v2.13.1)
## Changes 1/2/2026 (v2.13.1)
`release(v2.13.1): harden Docker startup perms + explicit inline MIME mapping (see #79)`
+6 -1
View File
@@ -33,13 +33,18 @@ ENV DEBIAN_FRONTEND=noninteractive \
PUID=99 PGID=100
# Install Apache, PHP, and required extensions
RUN apt-get update && \
RUN if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \
sed -i 's/^Components: .*/Components: main universe/' /etc/apt/sources.list.d/ubuntu.sources; \
fi && \
apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
apache2 \
php php-json php-curl php-zip php-mbstring php-gd php-xml \
ca-certificates curl git openssl \
smbclient \
7zip \
unar \
clamav clamav-freshclam \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
+12 -11
View File
@@ -5,8 +5,8 @@
/**
* @OA\Post(
* path="/api/file/downloadZip.php",
* summary="Download multiple files as a ZIP",
* description="Requires view access (or own-only with ownership). May be gated by account flag.",
* summary="Queue an archive download",
* description="Queues a background archive build. Requires view access (or own-only with ownership). May be gated by account flag.",
* operationId="downloadZip",
* tags={"Files"},
* security={{"cookieAuth": {}}},
@@ -16,18 +16,19 @@
* @OA\JsonContent(
* required={"folder","files"},
* @OA\Property(property="folder", type="string", example="root"),
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"a.jpg","b.png"})
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"a.jpg","b.png"}),
* @OA\Property(property="format", type="string", example="zip", enum={"zip","7z"}, description="Archive format")
* )
* ),
* @OA\Response(
* response=200,
* description="ZIP archive",
* content={
* "application/zip": @OA\MediaType(
* mediaType="application/zip",
* @OA\Schema(type="string", format="binary")
* )
* }
* description="Archive job queued",
* @OA\JsonContent(
* @OA\Property(property="ok", type="boolean", example=true),
* @OA\Property(property="token", type="string"),
* @OA\Property(property="statusUrl", type="string"),
* @OA\Property(property="downloadUrl", type="string")
* )
* ),
* @OA\Response(response=400, description="Invalid input"),
* @OA\Response(response=401, description="Unauthorized"),
@@ -40,4 +41,4 @@ require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController();
$fileController->downloadZip();
$fileController->downloadZip();
+4 -4
View File
@@ -4,14 +4,14 @@
/**
* @OA\Get(
* path="/api/file/downloadZipFile.php",
* summary="Download a finished ZIP by token",
* description="Streams the zip once; token is one-shot.",
* summary="Download a finished archive by token",
* description="Streams the archive once; token is one-shot.",
* operationId="downloadZipFile",
* tags={"Files"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
* @OA\Response(response=200, description="ZIP stream"),
* @OA\Response(response=200, description="Archive stream"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=404, description="Not found")
* )
@@ -21,4 +21,4 @@ require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->downloadZipFile();
$controller->downloadZipFile();
+4 -4
View File
@@ -4,8 +4,8 @@
/**
* @OA\Post(
* path="/api/file/extractZip.php",
* summary="Extract ZIP file(s) into a folder",
* description="Requires write access on the target folder.",
* summary="Extract archive file(s) into a folder",
* description="Supports ZIP/7Z and RAR extraction via server tools. Requires write access on the target folder.",
* operationId="extractZip",
* tags={"Files"},
* security={{"cookieAuth": {}}},
@@ -15,7 +15,7 @@
* @OA\JsonContent(
* required={"folder","files"},
* @OA\Property(property="folder", type="string", example="root"),
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"archive.zip"})
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"archive.zip","archive.7z"})
* )
* ),
* @OA\Response(response=200, description="Extraction result (model-defined)"),
@@ -30,4 +30,4 @@ require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController();
$fileController->extractZip();
$fileController->extractZip();
+2 -2
View File
@@ -4,7 +4,7 @@
/**
* @OA\Get(
* path="/api/file/zipStatus.php",
* summary="Check status of a background ZIP build",
* summary="Check status of a background archive build",
* description="Returns status for the authenticated user's token.",
* operationId="zipStatus",
* tags={"Files"},
@@ -20,4 +20,4 @@ require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->zipStatus();
$controller->zipStatus();
+11 -6
View File
@@ -326,7 +326,7 @@
</div>
<div class="action-separator secondary-separator" aria-hidden="true"></div>
<div class="action-group secondary-actions">
<button id="downloadZipBtn" class="btn action-btn icon-only" disabled title="Download selected" data-i18n-title="download">
<button id="downloadZipBtn" class="btn action-btn icon-only" disabled title="Download archive" data-i18n-title="download_zip">
<span class="material-icons" aria-hidden="true">file_download</span>
</button>
<button id="copySelectedBtn" class="btn action-btn icon-only" disabled title="Copy" data-i18n-title="copy_files">
@@ -344,7 +344,7 @@
<button id="deleteSelectedBtn" class="btn action-btn icon-only" disabled title="Delete" data-i18n-title="delete_files">
<span class="material-icons" aria-hidden="true">delete</span>
</button>
<button id="extractZipBtn" class="btn action-btn icon-only" style="display: none;" disabled title="Extract zip" data-i18n-title="extract_zip_button">
<button id="extractZipBtn" class="btn action-btn icon-only" style="display: none;" disabled title="Extract archive" data-i18n-title="extract_zip_button">
<span class="material-icons" aria-hidden="true">unarchive</span>
</button>
<button id="toolbarMenuBtn" class="btn action-btn icon-only" title="More actions">
@@ -442,8 +442,13 @@
</div>
<div id="downloadZipModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
<p data-i18n-key="download_zip_prompt">Enter a name for the zip file:</p>
<h4 data-i18n-key="download_zip_title">Download Selected Files as Archive</h4>
<label for="archiveFormatSelect" data-i18n-key="download_archive_format" style="display:block; margin-top:10px;">Archive format</label>
<select id="archiveFormatSelect" class="form-control">
<option value="zip">ZIP (.zip)</option>
<option value="7z">7-Zip (.7z)</option>
</select>
<p data-i18n-key="download_zip_prompt" style="margin-top:10px;">Enter a name for the archive file:</p>
<input type="text" id="zipFileNameInput" class="form-control" data-i18n-placeholder="zip_placeholder"
placeholder="files.zip" />
<div class="modal-footer" style="margin-top:15px; text-align:right;">
@@ -579,7 +584,7 @@
data-action="download_zip"
data-when="any">
<i class="material-icons">archive</i>
<span>Download as ZIP</span>
<span>Download Archive</span>
</button>
<!-- NEW: multi-download without ZIP -->
@@ -594,7 +599,7 @@
data-action="extract_zip"
data-when="zip">
<i class="material-icons">unarchive</i>
<span>Extract ZIP</span>
<span>Extract Archive</span>
</button>
<div class="sep" data-when="any"></div>
+2 -2
View File
@@ -374,9 +374,9 @@ function renderFolderGrantsUI(principal, container, folders, grants) {
${group(
tf('write_full', 'Write/Modify'),
`
${toggle('write', tf('write_full', 'Write (file ops)'), writeMetaChecked, false, tf('write_help', 'File-level: upload, edit, rename, copy, delete, extract ZIPs (no folder creation).'))}
${toggle('write', tf('write_full', 'Write (file ops)'), writeMetaChecked, false, tf('write_help', 'File-level: upload, edit, rename, copy, delete, extract archives (no folder creation).'))}
${toggle('edit', tf('edit', 'Edit File'), g.edit, false, tf('edit_help', 'Edit file contents'))}
${toggle('extract', tf('extract', 'Extract ZIP'), g.extract, false, tf('extract_help', 'Extract ZIP archives'))}
${toggle('extract', tf('extract', 'Extract Archive'), g.extract, false, tf('extract_help', 'Extract archive files'))}
${toggle('rename', tf('rename', 'Rename File'), g.rename, false, tf('rename_help', 'Rename a file'))}
${toggle('copy', tf('copy', 'Copy File'), g.copy, false, tf('copy_help', 'Copy a file'))}
`,
+1 -1
View File
@@ -679,7 +679,7 @@ function renderAdminEncryptionSection({ config, dark }) {
</div>
<div class="small text-muted" style="margin-top:8px;">
${tf("encryption_v1_note", "Admin notes:<ul style=\"margin:6px 0 0 18px; padding:0;\"><li>Master key can be set via <code>FR_ENCRYPTION_MASTER_KEY</code> (env overrides the key file) or via <code>META_DIR/encryption_master.key</code> (32 raw bytes).</li><li>Encrypted folders are recursive; shares, shared-folder uploads, WebDAV, and ZIP create/extract are blocked under encrypted folders.</li><li>Video/audio previews are disabled (no HTTP Range) but users can still download files normally.</li></ul>")}
${tf("encryption_v1_note", "Admin notes:<ul style=\"margin:6px 0 0 18px; padding:0;\"><li>Master key can be set via <code>FR_ENCRYPTION_MASTER_KEY</code> (env overrides the key file) or via <code>META_DIR/encryption_master.key</code> (32 raw bytes).</li><li>Encrypted folders are recursive; shares, shared-folder uploads, WebDAV, and archive create/extract are blocked under encrypted folders.</li><li>Video/audio previews are disabled (no HTTP Range) but users can still download files normally.</li></ul>")}
</div>
</div>
`;
+26 -2
View File
@@ -2,6 +2,30 @@
import { t } from './i18n.js?v={{APP_QVER}}';
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
const ARCHIVE_EXTS = [
".zip",
".7z",
".tar",
".tar.gz",
".tgz",
".tar.bz2",
".tbz2",
".tar.xz",
".txz",
".gz",
".bz2",
".xz",
".rar"
];
const RAR_PART_RE = /\.r\d{2}$/i;
export function isArchiveFileName(name) {
const lower = String(name || "").toLowerCase();
if (!lower) return false;
if (RAR_PART_RE.test(lower)) return true;
return ARCHIVE_EXTS.some(ext => lower.endsWith(ext));
}
// Basic DOM Helpers
export function toggleVisibility(elementId, shouldShow) {
const element = document.getElementById(elementId);
@@ -60,7 +84,7 @@ export function updateFileActionButtons() {
const anySelected = selectedCheckboxes.length > 0;
const anyFolderSelected = selectedFolders.length > 0;
const anyZip = Array.from(selectedCheckboxes)
.some(cb => cb.value.toLowerCase().endsWith(".zip"));
.some(cb => isArchiveFileName(cb.value));
const singleSelected = selectedCheckboxes.length === 1;
const currentFolderCaps = window.currentFolderCaps || null;
const selectedFolderCaps = window.selectedFolderCaps || null;
@@ -158,7 +182,7 @@ export function updateFileActionButtons() {
if (shareBtn) shareBtn.style.display = (showFileActions && !inEncryptedFolder) ? "" : "none";
if (createBtn) createBtn.style.display = "";
// Extract ZIP still appears only when a .zip is selected (and file mode)
// Extract archive appears only when a supported archive is selected (and file mode)
if (extractZipBtn) extractZipBtn.style.display = (showFileActions && anyZip && !inEncryptedFolder) ? "" : "none";
// Finally disable the ones that are shown but shouldnt be clickable
+180 -102
View File
@@ -1,5 +1,5 @@
// fileActions.js
import { showToast, attachEnterKeyListener, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { showToast, attachEnterKeyListener, escapeHTML, isArchiveFileName } from './domUtils.js?v={{APP_QVER}}';
import {
loadFileList,
formatFolderName,
@@ -54,6 +54,54 @@ function getTransferTotalsForNames(names) {
};
}
const ARCHIVE_FORMATS = ["zip", "7z"];
const ARCHIVE_NAME_SUFFIXES = ["zip", "7z", "rar"];
const ARCHIVE_EXT_RE = /\.(zip|7z|rar)$/i;
function syncArchiveFormatSelect() {
const select = document.getElementById("archiveFormatSelect");
if (!select) return;
const rarOption = select.querySelector('option[value="rar"]');
if (rarOption) {
rarOption.remove();
}
if (!ARCHIVE_FORMATS.includes(select.value)) {
select.value = "zip";
}
}
function getSelectedArchiveFormat() {
const select = document.getElementById("archiveFormatSelect");
const value = select ? String(select.value || "").toLowerCase() : "zip";
return ARCHIVE_FORMATS.includes(value) ? value : "zip";
}
function normalizeArchiveName(raw, format) {
let name = String(raw || "").trim();
if (!name) return "";
const ext = format === "7z" ? "7z" : format;
const lower = name.toLowerCase();
for (const suffix of ARCHIVE_NAME_SUFFIXES) {
const token = "." + suffix;
if (lower.endsWith(token)) {
name = name.slice(0, -token.length);
break;
}
}
if (name === "") name = "files";
return name + "." + ext;
}
function updateArchiveNamePlaceholder(format) {
const input = document.getElementById("zipFileNameInput");
if (!input) return;
const ext = format === "7z" ? "7z" : format;
input.placeholder = `files.${ext}`;
if (input.value && ARCHIVE_EXT_RE.test(input.value)) {
input.value = normalizeArchiveName(input.value, format);
}
}
let __copyMoveSourcesCache = null;
function getActivePaneKey() {
@@ -359,7 +407,7 @@ export function handleDownloadMultiSelected(e) {
const caps = window.currentFolderCaps || null;
const inEncryptedFolder = !!(caps && caps.encryption && caps.encryption.encrypted);
// In encrypted folders, ZIP create is disabled. Allow plain downloads up to the limit only.
// In encrypted folders, archive creation is disabled. Allow plain downloads up to the limit only.
if (inEncryptedFolder) {
if (files.length > limit) {
showToast(`In encrypted folders, downloads are limited to ${limit} file(s) at a time.`, 'warning');
@@ -369,7 +417,7 @@ export function handleDownloadMultiSelected(e) {
return;
}
// Normal behavior: download individually up to the limit; ZIP for more than the limit.
// Normal behavior: download individually up to the limit; archive for more than the limit.
if (files.length > limit) {
handleDownloadZipSelected(e || new Event("click"));
return;
@@ -394,7 +442,7 @@ export function handleDownloadZipSelected(e) {
showToast(`In encrypted folders, downloads are limited to ${limit} file(s) at a time.`, 'warning');
return;
}
// If we got here via an old/hidden ZIP action, fall back to plain download.
// If we got here via an old/hidden archive action, fall back to plain download.
downloadSelectedFilesIndividually(files);
return;
}
@@ -406,6 +454,8 @@ export function handleDownloadZipSelected(e) {
}
window.filesToDownload = Array.from(checkboxes).map(chk => chk.value);
document.getElementById("downloadZipModal").style.display = "block";
syncArchiveFormatSelect();
updateArchiveNamePlaceholder(getSelectedArchiveFormat());
setTimeout(() => {
const input = document.getElementById("zipFileNameInput");
input.focus();
@@ -546,11 +596,11 @@ export function handleExtractZipSelected(e) {
showToast("No files selected.", 'warning');
return;
}
const zipFiles = Array.from(checkboxes)
const archiveFiles = Array.from(checkboxes)
.map(chk => chk.value)
.filter(name => name.toLowerCase().endsWith(".zip"));
if (!zipFiles.length) {
showToast("No zip files selected.", 'warning');
.filter(name => isArchiveFileName(name));
if (!archiveFiles.length) {
showToast("No archive files selected.", 'warning');
return;
}
@@ -577,27 +627,35 @@ export function handleExtractZipSelected(e) {
},
body: JSON.stringify({
folder: window.currentFolder || "root",
files: zipFiles
files: archiveFiles
})
})
.then(response => response.json())
.then(data => {
modal.style.display = "none";
const extracted = Array.isArray(data.extractedFiles) ? data.extractedFiles : [];
const extractedMsg = extracted.length ? ("Extracted: " + extracted.join(", ")) : "Archive extracted.";
const warning = (data && typeof data.warning === "string" && data.warning.trim()) ? data.warning.trim() : "";
if (data.success) {
let msg = "Zip file(s) extracted successfully!";
if (Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
msg = "Extracted: " + data.extractedFiles.join(", ");
if (warning) {
showToast(`${extractedMsg} Warning: ${warning}`, 'warning');
} else {
showToast(extractedMsg, 'success');
}
showToast(msg, 'success');
loadFileList(window.currentFolder);
} else if (extracted.length) {
const warnMsg = warning || data.error || "Some files failed to extract.";
showToast(`${extractedMsg} Warning: ${warnMsg}`, 'warning');
} else {
showToast("Error extracting zip: " + (data.error || "Unknown error"), 'error');
const errMsg = warning || data.error || "Unknown error";
showToast("Error extracting archive: " + errMsg, 'error');
}
loadFileList(window.currentFolder);
})
.catch(error => {
modal.style.display = "none";
console.error("Error extracting zip files:", error);
showToast("Error extracting zip files.", 'error');
console.error("Error extracting archive files:", error);
showToast("Error extracting archive files.", 'error');
});
}
@@ -606,6 +664,7 @@ document.addEventListener("DOMContentLoaded", () => {
const progressModal = document.getElementById("downloadProgressModal");
const cancelZipBtn = document.getElementById("cancelDownloadZip");
const confirmZipBtn = document.getElementById("confirmDownloadZip");
const formatSelect = document.getElementById("archiveFormatSelect");
const cancelCreate = document.getElementById('cancelCreateFile');
if (cancelCreate && !cancelCreate.__wiredCreateFile) {
@@ -631,41 +690,51 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// 2) Confirm button kicks off the zip+download
if (formatSelect) {
syncArchiveFormatSelect();
formatSelect.addEventListener("change", () => {
updateArchiveNamePlaceholder(getSelectedArchiveFormat());
});
}
// 2) Confirm button kicks off the archive+download
if (confirmZipBtn) {
confirmZipBtn.setAttribute("data-default", "");
confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) { showToast("Please enter a name for the zip file.", 'warning'); return; }
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
let archiveName = '';
let ui = null;
try {
// a) Validate archive filename
const format = getSelectedArchiveFormat();
const rawName = document.getElementById("zipFileNameInput").value.trim();
if (!rawName) { showToast("Please enter a name for the archive file.", 'warning'); return; }
archiveName = normalizeArchiveName(rawName, format);
// b) Hide the nameinput modal, show the progress modal
zipNameModal.style.display = "none";
progressModal.style.display = "block";
// b) Hide the nameinput modal, show the progress modal
zipNameModal.style.display = "none";
progressModal.style.display = "block";
// c) Title text (optional)
const titleEl = document.getElementById("downloadProgressTitle");
if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
// c) Title text (optional)
const titleEl = document.getElementById("downloadProgressTitle");
if (titleEl) titleEl.textContent = `Preparing ${archiveName}`;
// d) Queue the job
const res = await fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
});
const jsr = await res.json().catch(() => ({}));
if (!res.ok || !jsr.ok) {
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
throw new Error(msg);
}
const token = jsr.token;
const statusUrl = jsr.statusUrl;
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
// d) Queue the job
const res = await fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload, format })
});
const jsr = await res.json().catch(() => ({}));
if (!res.ok || !jsr.ok) {
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
throw new Error(msg);
}
const statusUrl = jsr.statusUrl;
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(archiveName);
// Ensure a progress UI exists in the modal
function ensureZipProgressUI() {
// Ensure a progress UI exists in the modal
function ensureZipProgressUI() {
const modalEl = document.getElementById("downloadProgressModal");
if (!modalEl) {
// really shouldn't happen, but fall back to body
@@ -753,71 +822,80 @@ document.addEventListener("DOMContentLoaded", () => {
return `${m}:${s.toString().padStart(2, '0')}`;
}
const ui = ensureZipProgressUI();
const t0 = Date.now();
ui = ensureZipProgressUI();
const t0 = Date.now();
// e) Poll until ready
while (true) {
await new Promise(r => setTimeout(r, 1200));
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
credentials: "include", cache: "no-store",
}).then(r => r.json());
// e) Poll until ready
while (true) {
await new Promise(r => setTimeout(r, 1200));
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
credentials: "include", cache: "no-store",
}).then(r => r.json());
if (s.error) throw new Error(s.error);
if (ui.title) ui.title.textContent = `Preparing ${zipName}`;
if (s.error) throw new Error(s.error);
if (ui.title) ui.title.textContent = `Preparing ${archiveName}`;
// --- RENDER PROGRESS ---
if (typeof s.pct === "number" && ui.bar && ui.text) {
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
ui.hideSpinner && ui.hideSpinner();
const filesDone = s.filesDone ?? 0;
const filesTotal = s.filesTotal ?? 0;
const bytesDone = s.bytesDone ?? 0;
const bytesTotal = s.bytesTotal ?? 0;
// --- RENDER PROGRESS ---
if (s.status === "queued" && ui.text) {
ui.text.textContent = "Queued… starting worker";
} else if (typeof s.pct === "number" && ui.bar && ui.text) {
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
ui.hideSpinner && ui.hideSpinner();
const filesDone = s.filesDone ?? 0;
const filesTotal = s.filesTotal ?? 0;
const bytesDone = s.bytesDone ?? 0;
const bytesTotal = s.bytesTotal ?? 0;
// Determinate 098% while enumerating
const pct = Math.max(0, Math.min(98, s.pct | 0));
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
ui.bar.value = pct;
ui.text.textContent =
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
} else {
// FINALIZING: keep progress at 100% and show timer + selected totals
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
ui.bar.value = 100; // lock at 100 during finalizing
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
ui.text.textContent = `Finalizing… ${mmss(since)}${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
// Determinate 098% while enumerating
const pct = Math.max(0, Math.min(98, s.pct | 0));
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
ui.bar.value = pct;
ui.text.textContent =
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
} else {
// FINALIZING: keep progress at 100% and show timer + selected totals
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
ui.bar.value = 100; // lock at 100 during finalizing
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
ui.text.textContent = `Finalizing… ${mmss(since)}${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
}
} else if (ui.text) {
ui.text.textContent = "Still preparing…";
}
} else if (ui.text) {
ui.text.textContent = "Still preparing…";
}
// --- /RENDER ---
// --- /RENDER ---
if (s.ready) {
// Snap to 100 and close modal just before download
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
progressModal.style.display = "none";
await new Promise(r => setTimeout(r, 0));
break;
if (s.ready) {
// Snap to 100 and close modal just before download
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
progressModal.style.display = "none";
await new Promise(r => setTimeout(r, 0));
break;
}
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing archive");
}
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
// f) Trigger download
const a = document.createElement("a");
a.href = downloadUrl;
a.download = archiveName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
// g) Reset for next time
if (ui.bar) ui.bar.value = 0;
if (ui.text) ui.text.textContent = "";
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
} catch (err) {
const msg = (err && err.message) ? err.message : "Failed to prepare archive.";
progressModal.style.display = "none";
if (ui && ui.bar) ui.bar.value = 0;
if (ui && ui.text) ui.text.textContent = "";
showToast(msg, 'error');
}
// f) Trigger download
const a = document.createElement("a");
a.href = downloadUrl;
a.download = zipName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
// g) Reset for next time
if (ui.bar) ui.bar.value = 0;
if (ui.text) ui.text.textContent = "";
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
});
}
});
+2 -2
View File
@@ -137,7 +137,7 @@ function updateEncryptedFolderBanner(folder) {
text.className = 'fr-enc-text';
text.textContent =
`This folder${rootLabel} is encrypted. ` +
'Video/audio previews, WebDAV, ONLYOFFICE, and ZIP create/extract are disabled.';
'Video/audio previews, WebDAV, ONLYOFFICE, and archive create/extract are disabled.';
el.append(pill, text);
}
@@ -7599,7 +7599,7 @@ export function downloadSelectedFilesIndividually(fileObjs) {
const inEncrypted = !!(window.currentFolderCaps && window.currentFolderCaps.encryption && window.currentFolderCaps.encryption.encrypted);
const msg = inEncrypted
? `You selected ${mapped.length} files. In encrypted folders, downloads are limited to ${limit} files at a time.`
: (t('too_many_plain_downloads') || `You selected ${mapped.length} files. For more than ${limit} files, please use "Download as ZIP".`);
: (t('too_many_plain_downloads') || `You selected ${mapped.length} files. For more than ${limit} files, please use "Download as Archive".`);
showToast(msg, 'warning');
return;
}
+2 -5
View File
@@ -1,6 +1,6 @@
// fileMenu.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
import { updateRowHighlight, escapeHTML, isArchiveFileName } from './domUtils.js?v={{APP_QVER}}';
import {
handleDeleteSelected, handleCopySelected, handleMoveSelected,
handleDownloadZipSelected, handleExtractZipSelected,
@@ -10,9 +10,6 @@ import { previewFile, buildPreviewUrl, openShareModal } from './filePreview.js?v
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData, downloadSelectedFilesIndividually, startInlineRenameFromContext } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
const MENU_ID = 'fileContextMenu';
function qMenu() { return document.getElementById(MENU_ID); }
@@ -137,7 +134,7 @@ function currentSelection() {
const any = files.length > 0;
const one = files.length === 1;
const many = files.length > 1;
const anyZip = files.some(f => f.name.toLowerCase().endsWith('.zip'));
const anyZip = files.some(f => isArchiveFileName(f.name));
const file = one ? files[0] : null;
const canEditFlag = !!(file && canEditFile(file.name));
+30 -25
View File
@@ -61,8 +61,8 @@ const translations = {
"copy_selected": "Copy Selected",
"move_selected": "Move Selected",
"tag_selected": "Tag Selected",
"download_zip": "Download Zip",
"extract_zip": "Extract Zip",
"download_zip": "Download Archive",
"extract_zip": "Extract Archive",
"preview": "Preview",
"preview_too_large": "Preview too large to display.",
"edit": "Edit",
@@ -118,9 +118,10 @@ const translations = {
"move_files_title": "Move Selected Files",
"move_files_message": "Select a target folder for moving the selected files:",
"move": "Move",
"extract_zip_button": "Extract Zip",
"download_zip_title": "Download Selected Files as Zip",
"download_zip_prompt": "Enter a name for the zip file:",
"extract_zip_button": "Extract Archive",
"download_zip_title": "Download Selected Files as Archive",
"download_zip_prompt": "Enter a name for the archive file:",
"download_archive_format": "Archive format",
"zip_placeholder": "files.zip",
"share": "Share",
"total_files": "Total Files",
@@ -533,8 +534,8 @@ const translations = {
"copy_selected": "Copiar seleccionados",
"move_selected": "Mover seleccionados",
"tag_selected": "Etiquetar seleccionados",
"download_zip": "Descargar Zip",
"extract_zip": "Extraer Zip",
"download_zip": "Descargar archivo",
"extract_zip": "Extraer archivo",
"preview": "Vista previa",
"edit": "Editar",
"rename": "Renombrar",
@@ -588,9 +589,10 @@ const translations = {
"move_files_title": "Mover archivos seleccionados",
"move_files_message": "Seleccione una carpeta destino para mover los archivos seleccionados:",
"move": "Mover",
"extract_zip_button": "Extraer Zip",
"download_zip_title": "Descargar archivos seleccionados en un Zip",
"download_zip_prompt": "Ingrese un nombre para el archivo Zip:",
"extract_zip_button": "Extraer archivo",
"download_zip_title": "Descargar archivos seleccionados en un archivo",
"download_zip_prompt": "Ingrese un nombre para el archivo:",
"download_archive_format": "Formato de archivo",
"zip_placeholder": "files.zip",
// Login Form keys:
@@ -772,8 +774,8 @@ const translations = {
"copy_selected": "Copier la sélection",
"move_selected": "Déplacer la sélection",
"tag_selected": "Étiqueter la sélection",
"download_zip": "Télécharger le Zip",
"extract_zip": "Extraire le Zip",
"download_zip": "Télécharger l'archive",
"extract_zip": "Extraire l'archive",
"preview": "Aperçu",
"edit": "Modifier",
"rename": "Renommer",
@@ -826,9 +828,10 @@ const translations = {
"move_files_title": "Déplacer les fichiers sélectionnés",
"move_files_message": "Sélectionnez un dossier de destination pour déplacer les fichiers sélectionnés :",
"move": "Déplacer",
"extract_zip_button": "Extraire le Zip",
"download_zip_title": "Télécharger les fichiers sélectionnés en Zip",
"download_zip_prompt": "Entrez un nom pour le fichier Zip :",
"extract_zip_button": "Extraire l'archive",
"download_zip_title": "Télécharger les fichiers sélectionnés en archive",
"download_zip_prompt": "Entrez un nom pour le fichier d'archive :",
"download_archive_format": "Format d'archive",
"zip_placeholder": "files.zip",
// Login Form keys:
@@ -1010,8 +1013,8 @@ const translations = {
"copy_selected": "Ausgewählte kopieren",
"move_selected": "Ausgewählte verschieben",
"tag_selected": "Ausgewählte taggen",
"download_zip": "Zip herunterladen",
"extract_zip": "Zip entpacken",
"download_zip": "Archiv herunterladen",
"extract_zip": "Archiv entpacken",
"preview": "Vorschau",
"edit": "Bearbeiten",
"rename": "Umbenennen",
@@ -1065,9 +1068,10 @@ const translations = {
"move_files_title": "Ausgewählte Dateien verschieben",
"move_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu verschieben:",
"move": "Verschieben",
"extract_zip_button": "Zip entpacken",
"download_zip_title": "Ausgewählte Dateien als Zip herunterladen",
"download_zip_prompt": "Geben Sie einen Namen für die Zip-Datei ein:",
"extract_zip_button": "Archiv entpacken",
"download_zip_title": "Ausgewählte Dateien als Archiv herunterladen",
"download_zip_prompt": "Geben Sie einen Namen für die Archivdatei ein:",
"download_archive_format": "Archivformat",
"zip_placeholder": "files.zip",
"share": "Teilen",
"total_files": "Gesamtanzahl",
@@ -1259,8 +1263,8 @@ const translations = {
"copy_selected": "复制所选",
"move_selected": "移动所选",
"tag_selected": "标记所选",
"download_zip": "下载 ZIP",
"extract_zip": "解压 ZIP",
"download_zip": "下载压缩包",
"extract_zip": "解压压缩包",
"preview": "预览",
"edit": "编辑",
"rename": "重命名",
@@ -1312,9 +1316,10 @@ const translations = {
"move_files_title": "移动所选文件",
"move_files_message": "选择目标文件夹以移动所选文件:",
"move": "移动",
"extract_zip_button": "解压 ZIP",
"download_zip_title": "将所选文件打包为 ZIP 下载",
"download_zip_prompt": "输入 ZIP 文件名:",
"extract_zip_button": "解压压缩包",
"download_zip_title": "将所选文件打包为压缩包下载",
"download_zip_prompt": "输入压缩包文件名:",
"download_archive_format": "压缩格式",
"zip_placeholder": "files.zip",
"share": "分享",
"total_files": "文件总数",
+13
View File
@@ -1386,6 +1386,18 @@ function bindDarkMode() {
alert('Login failed');
});
}
function focusLoginUsername() {
const input = document.getElementById('loginUsername');
const form = document.getElementById('authForm');
if (!input || !form || input.disabled) return;
if (form.hasAttribute('hidden') || form.style.display === 'none') return;
try {
if (getComputedStyle(form).display === 'none') return;
} catch (e) { }
requestAnimationFrame(() => {
try { input.focus(); } catch (e) { }
});
}
function afterLogin() {
// If index.html was opened with ?redirect=<url>, honor that first
try {
@@ -1641,6 +1653,7 @@ function bindDarkMode() {
keepCreateDropdownWired();
wireModalEnterDefault();
showLoginTip('Please log in to continue');
focusLoginUsername();
if (overlay) overlay.style.display = 'none';
}, { once: true });
+151 -53
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
require __DIR__ . '/../../config/config.php';
require __DIR__ . '/../../src/models/FileModel.php';
require __DIR__ . '/../../src/lib/SourceContext.php';
require_once __DIR__ . '/../../src/lib/SourceContext.php';
$token = $argv[1] ?? '';
$token = preg_replace('/[^a-f0-9]/','',$token);
@@ -57,6 +57,35 @@ $touchPhase = function(string $phase) use (&$job, $save) {
$save();
};
$format = strtolower((string)($job['format'] ?? 'zip'));
if (!in_array($format, ['zip', '7z'], true)) {
$job['status'] = 'error';
$job['error'] = 'Unsupported archive format.';
$save();
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
exit(0);
}
$job['format'] = $format;
$findBin = function(array $candidates): ?string {
foreach ($candidates as $bin) {
if ($bin === '') continue;
if (str_contains($bin, '/')) {
if (is_file($bin) && is_executable($bin)) {
return $bin;
}
continue;
}
$out = [];
$rc = 1;
@exec('command -v ' . escapeshellarg($bin) . ' 2>/dev/null', $out, $rc);
if ($rc === 0 && !empty($out[0])) {
return trim($out[0]);
}
}
return null;
};
// Init timing
if (empty($job['startedAt'])) {
$job['startedAt'] = time();
@@ -101,7 +130,7 @@ try {
$fp = $folderPathReal . DIRECTORY_SEPARATOR . $bn;
if (is_file($fp)) $filesToZip[] = $fp;
}
if (!$filesToZip) throw new RuntimeException('No valid files to zip.');
if (!$filesToZip) throw new RuntimeException('No valid files to archive.');
// Totals for progress
$filesTotal = count($filesToZip);
@@ -120,71 +149,140 @@ try {
$job['phase'] = 'zipping';
$save();
// Create final zip path in META_DIR/ziptmp
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
if ($format === 'zip') {
// Create final zip path in META_DIR/ziptmp
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Could not create zip archive.');
}
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Could not create zip archive.');
}
// Enumerate files; report up to 98%
$bytesDone = 0;
$filesDone = 0;
foreach ($filesToZip as $fp) {
$bn = basename($fp);
$zip->addFile($fp, $bn);
// Enumerate files; report up to 98%
$bytesDone = 0;
$filesDone = 0;
foreach ($filesToZip as $fp) {
$bn = basename($fp);
$zip->addFile($fp, $bn);
$filesDone++;
$sz = @filesize($fp);
if ($sz !== false) $bytesDone += (int)$sz;
$filesDone++;
$sz = @filesize($fp);
if ($sz !== false) $bytesDone += (int)$sz;
$job['filesDone'] = $filesDone;
$job['bytesDone'] = $bytesDone;
$job['current'] = $bn;
$job['filesDone'] = $filesDone;
$job['bytesDone'] = $bytesDone;
$job['current'] = $bn;
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
if ($pct < 0) $pct = 0;
if ($pct > 98) $pct = 98;
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
if ($pct < 0) $pct = 0;
if ($pct > 98) $pct = 98;
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
$save();
}
// Finalizing (this is where libzip writes & renames)
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
$job['phase'] = 'finalizing';
$job['finalizeAt'] = time();
// Publish selected totals for a truthful UI during finalizing,
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
$job['selectedFiles'] = $filesTotal;
$job['selectedBytes'] = $bytesTotal;
$job['filesDone'] = null;
$job['bytesDone'] = null;
$job['current'] = null;
$save();
// ---- finalize the zip on disk ----
$ok = $zip->close();
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
if (!$ok || !is_file($zipPath)) {
$job['status'] = 'error';
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
$save();
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
exit(0);
}
$job['status'] = 'done';
$job['zipPath'] = $zipPath;
$job['pct'] = 100;
$job['phase'] = 'finalized';
$save();
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
exit(0);
}
// Finalizing (this is where libzip writes & renames)
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
$job['phase'] = 'finalizing';
$job['finalizeAt'] = time();
$archiveExt = ($format === '7z') ? '7z' : 'zip';
$archiveName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . $archiveExt;
$archivePath = $root . DIRECTORY_SEPARATOR . $archiveName;
// Publish selected totals for a truthful UI during finalizing,
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
$job['selectedFiles'] = $filesTotal;
$job['selectedBytes'] = $bytesTotal;
$job['filesDone'] = null;
$job['bytesDone'] = null;
$job['current'] = null;
$listFile = tempnam($root, '7zlist-');
if ($listFile === false) {
throw new RuntimeException('Failed to prepare archive file list.');
}
$save();
$relNames = [];
foreach ($filesToZip as $fp) {
$relNames[] = basename($fp);
}
if (file_put_contents($listFile, implode("\n", $relNames) . "\n", LOCK_EX) === false) {
@unlink($listFile);
throw new RuntimeException('Failed to write archive file list.');
}
// ---- finalize the zip on disk ----
$ok = $zip->close();
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
if (!$ok || !is_file($zipPath)) {
$job['status'] = 'error';
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
$job['phase'] = 'finalizing';
$job['finalizeAt'] = time();
$job['selectedFiles'] = $filesTotal;
$job['selectedBytes'] = $bytesTotal;
$job['filesDone'] = null;
$job['bytesDone'] = null;
$job['current'] = null;
$save();
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
exit(0);
}
$job['status'] = 'done';
$job['zipPath'] = $zipPath;
$job['pct'] = 100;
$job['phase'] = 'finalized';
$save();
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
$cwd = getcwd();
if ($cwd !== false) {
@chdir($folderPathReal);
}
$out = [];
$rc = 1;
$bin = $findBin(['7zz', '/usr/bin/7zz', '/usr/local/bin/7zz', '/bin/7zz', '7z', '/usr/bin/7z', '/usr/local/bin/7z', '/bin/7z']);
if (!$bin) {
throw new RuntimeException('7z is not available on the server.');
}
$workArg = '-w' . $root;
$cmd = escapeshellarg($bin) . ' a -t7z -y -bd ' . escapeshellarg($workArg) . ' ' . escapeshellarg($archivePath) . ' ' . escapeshellarg('@' . $listFile);
@exec($cmd, $out, $rc);
if ($cwd !== false) {
@chdir($cwd);
}
@unlink($listFile);
if ($rc !== 0 || !is_file($archivePath)) {
$detail = trim(implode("\n", $out));
if (strlen($detail) > 200) $detail = substr($detail, 0, 200) . '...';
$job['status'] = 'error';
$job['error'] = 'Failed to create archive' . ($detail ? ': ' . $detail : '');
$save();
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
exit(0);
}
$job['status'] = 'done';
$job['zipPath'] = $archivePath;
$job['pct'] = 100;
$job['phase'] = 'finalized';
$save();
file_put_contents($logFile, "[".date('c')."] done {$format}={$archivePath}\n", FILE_APPEND);
exit(0);
} catch (Throwable $e) {
$job['status'] = 'error';
$job['error'] = 'Worker exception: '.$e->getMessage();
+119 -12
View File
@@ -2046,6 +2046,18 @@ class FileController
'finalizeAt' => $job['finalizeAt'] ?? null,
];
if (($job['status'] ?? '') === 'queued') {
$queuedAt = (int)($job['ctime'] ?? 0);
if ($queuedAt > 0 && empty($job['startedAt'])) {
$age = time() - $queuedAt;
if ($age > 20) {
$out['status'] = 'error';
$out['error'] = 'Archive worker did not start. Check server PHP CLI and permissions.';
$out['ready'] = false;
}
}
}
if ($ready) {
$out['size'] = @filesize($job['zipPath']) ?: null;
$out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token);
@@ -2115,14 +2127,38 @@ class FileController
@ini_set('output_buffering', 'off');
while (ob_get_level() > 0) @ob_end_clean();
$format = strtolower((string)($job['format'] ?? 'zip'));
if (!in_array($format, ['zip', '7z'], true)) {
@unlink($zipReal);
http_response_code(400);
echo "Unsupported archive format.";
return;
}
$ext = ($format === '7z') ? '7z' : 'zip';
$mimeMap = [
'zip' => 'application/zip',
'7z' => 'application/x-7z-compressed',
];
$mimeType = $mimeMap[$format] ?? 'application/octet-stream';
@clearstatcache(true, $zipReal);
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$_GET['name']) : 'files.zip';
if ($name === '' || str_ends_with($name, '.')) $name = 'files.zip';
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$_GET['name']) : 'files.' . $ext;
if ($name === '' || str_ends_with($name, '.')) $name = 'files';
$lower = strtolower($name);
foreach (['.zip', '.7z'] as $suffix) {
if (str_ends_with($lower, $suffix)) {
$name = substr($name, 0, -strlen($suffix));
break;
}
}
$name = rtrim($name, '.');
if ($name === '') $name = 'files';
$name .= '.' . $ext;
$size = (int)@filesize($zipReal);
header('X-Accel-Buffering: no');
header('X-Content-Type-Options: nosniff');
header('Content-Type: application/zip');
header('Content-Type: ' . $mimeType);
header('Content-Disposition: attachment; filename="' . $name . '"');
if ($size > 0) header('Content-Length: ' . $size);
header('Cache-Control: no-store, no-cache, must-revalidate');
@@ -2146,7 +2182,7 @@ class FileController
$storage = StorageRegistry::getAdapter();
if (!$storage->isLocal()) {
$this->_jsonOut(["error" => "ZIP operations are not supported for remote storage."], 400);
$this->_jsonOut(["error" => "Archive operations are not supported for remote storage."], 400);
return;
}
@@ -2163,13 +2199,50 @@ class FileController
return;
}
$format = strtolower(trim((string)($data['format'] ?? 'zip')));
if ($format === '') {
$format = 'zip';
}
$allowedFormats = ['zip', '7z'];
if (!in_array($format, $allowedFormats, true)) {
$msg = "Invalid archive format.";
$this->_jsonOut(["error" => $msg], 400);
return;
}
$findBin = function (array $candidates): ?string {
foreach ($candidates as $bin) {
if ($bin === '') continue;
if (str_contains($bin, '/')) {
if (is_file($bin) && is_executable($bin)) {
return $bin;
}
continue;
}
$out = [];
$rc = 1;
@exec('command -v ' . escapeshellarg($bin) . ' 2>/dev/null', $out, $rc);
if ($rc === 0 && !empty($out[0])) {
return trim($out[0]);
}
}
return null;
};
if ($format === '7z') {
$bin = $findBin(['7zz', '/usr/bin/7zz', '/usr/local/bin/7zz', '/bin/7zz', '7z', '/usr/bin/7z', '/usr/local/bin/7z', '/bin/7z']);
if (!$bin) {
$this->_jsonOut(["error" => "7z is not available on the server; cannot create 7z archives."], 400);
return;
}
}
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
$sourceId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';
// Optional zip gate by account flag
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403);
$this->_jsonOut(["error" => "Archive downloads are not allowed for your account."], 403);
return;
}
@@ -2210,7 +2283,7 @@ class FileController
@chmod($tokDir, 0700);
@chmod($logDir, 0700);
if (!is_dir($tokDir) || !is_writable($tokDir)) {
$this->_jsonOut(["error" => "ZIP token dir not writable."], 500);
$this->_jsonOut(["error" => "Archive token dir not writable."], 500);
return;
}
@@ -2237,17 +2310,50 @@ class FileController
foreach ($tokens as $tf) {
$job = json_decode((string)@file_get_contents($tf), true) ?: [];
$st = $job['status'] ?? 'unknown';
$pid = (int)($job['spawn']['pid'] ?? 0);
$tokenKey = pathinfo($tf, PATHINFO_FILENAME);
$pidAlive = false;
$pidCmdChecked = false;
$pidLooksLikeWorker = false;
if ($pid > 0) {
if (is_dir('/proc/' . $pid)) {
$pidAlive = true;
$cmdline = @file_get_contents('/proc/' . $pid . '/cmdline');
if (is_string($cmdline) && $cmdline !== '') {
$pidCmdChecked = true;
$cmdline = str_replace("\0", ' ', $cmdline);
if (str_contains($cmdline, 'zip_worker.php') && ($tokenKey === '' || str_contains($cmdline, $tokenKey))) {
$pidLooksLikeWorker = true;
}
}
} elseif (function_exists('posix_kill')) {
$pidAlive = @posix_kill($pid, 0);
}
}
$queuedAt = (int)($job['ctime'] ?? 0);
$startedAt = (int)($job['startedAt'] ?? 0);
$queuedAge = ($queuedAt > 0) ? ($now - $queuedAt) : 0;
$staleQueued = ($st === 'queued' && $startedAt <= 0 && $queuedAge > 120);
$staleWorkingNoPid = (in_array($st, ['working', 'finalizing'], true) && $pid <= 0 && $queuedAge > 120);
$staleRunning = (in_array($st, ['working', 'finalizing'], true) && $pid > 0 && !$pidAlive);
$stalePidMismatch = (in_array($st, ['working', 'finalizing'], true) && $pid > 0 && $pidAlive && $pidCmdChecked && !$pidLooksLikeWorker && $queuedAge > 120);
if ($staleQueued || $staleWorkingNoPid || $staleRunning || $stalePidMismatch) {
@unlink($tf);
continue;
}
if ($st === 'queued' || $st === 'working' || $st === 'finalizing') {
$all++;
if (($job['user'] ?? '') === $username) $mine++;
}
}
if ($mine >= $perUserCap) {
$this->_jsonOut(["error" => "You already have ZIP jobs running. Try again shortly."], 429);
$this->_jsonOut(["error" => "You already have archive jobs running. Try again shortly."], 429);
return;
}
if ($all >= $globalCap) {
$this->_jsonOut(["error" => "ZIP queue is busy. Try again shortly."], 429);
$this->_jsonOut(["error" => "Archive queue is busy. Try again shortly."], 429);
return;
}
@@ -2259,6 +2365,7 @@ class FileController
'folder' => $folder,
'files' => array_values($files),
'sourceId' => $sourceId,
'format' => $format,
'status' => 'queued',
'ctime' => time(),
'startedAt' => null,
@@ -2267,7 +2374,7 @@ class FileController
'error' => null
];
if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) {
$this->_jsonOut(["error" => "Failed to create zip job."], 500);
$this->_jsonOut(["error" => "Failed to create archive job."], 500);
return;
}
@@ -2277,7 +2384,7 @@ class FileController
$job['status'] = 'error';
$job['error'] = 'Spawn failed: ' . $spawn['error'];
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
$this->_jsonOut(["error" => "Failed to enqueue ZIP: " . $spawn['error']], 500);
$this->_jsonOut(["error" => "Failed to enqueue archive: " . $spawn['error']], 500);
return;
}
@@ -2290,7 +2397,7 @@ class FileController
]);
} catch (Throwable $e) {
error_log('FileController::downloadZip enqueue error: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500);
$this->_jsonOut(['error' => 'Internal error while queuing archive.'], 500);
}
}
@@ -2303,7 +2410,7 @@ class FileController
$storage = StorageRegistry::getAdapter();
if (!$storage->isLocal()) {
$this->_jsonOut(["error" => "ZIP operations are not supported for remote storage."], 400);
$this->_jsonOut(["error" => "Archive operations are not supported for remote storage."], 400);
return;
}
+890 -93
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -227,4 +227,4 @@ echo "🔥 Starting Apache..."
# Stream only the chosen logs; -n0 = don't dump history, -F = follow across rotations/creation
[ "${STREAM_ERR}" = "true" ] && tail -n0 -F /var/www/metadata/log/error.log 2>/dev/null &
[ "${STREAM_ACC}" = "true" ] && tail -n0 -F /var/www/metadata/log/access.log 2>/dev/null &
exec apachectl -D FOREGROUND
exec apachectl -D FOREGROUND