Fix Remove fallback ETag, make etag optional, fix multipart ETag storage, fix request entity too large error due to mishandled multipart uploads

This commit is contained in:
2026-01-01 21:51:01 +08:00
parent e792b86485
commit b3dce8d13e
5 changed files with 417 additions and 68 deletions

View File

@@ -451,6 +451,12 @@
</div>
</div>
<div id="policyErrorDetail" class="text-danger small mt-1 d-none"></div>
<div id="policyReadonlyHint" class="alert alert-secondary small py-2 px-3 mt-2 mb-0 d-none">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</svg>
This preset is read-only. Select <strong>Custom JSON</strong> to edit the policy manually.
</div>
<div class="form-text d-flex align-items-start gap-2 mt-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="flex-shrink-0 mt-1 text-muted" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
@@ -3320,8 +3326,14 @@
}
};
const policyReadonlyHint = document.getElementById('policyReadonlyHint');
const applyPolicyPreset = (preset) => {
if (!policyTextarea || !policyMode) return;
const isPresetMode = preset === 'private' || preset === 'public';
if (policyReadonlyHint) {
policyReadonlyHint.classList.toggle('d-none', !isPresetMode);
}
switch (preset) {
case 'private':
setPolicyTextareaState(true);
@@ -3329,7 +3341,7 @@
policyMode.value = 'delete';
break;
case 'public':
setPolicyTextareaState(false);
setPolicyTextareaState(true);
policyTextarea.value = publicPolicyTemplate || '';
policyMode.value = 'upsert';
break;
@@ -4160,42 +4172,245 @@
if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = '';
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
if (uploadFileInput) uploadFileInput.disabled = false;
const progressStack = document.querySelector('[data-upload-progress]');
if (progressStack) progressStack.innerHTML = '';
if (uploadDropZone) {
uploadDropZone.classList.remove('upload-locked');
uploadDropZone.style.pointerEvents = '';
}
isUploading = false;
};
const uploadSingleFile = async (file, keyPrefix = '', metadata = null) => {
const formData = new FormData();
formData.append('object', file);
const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
const CHUNK_SIZE = 8 * 1024 * 1024;
const uploadProgressStack = document.querySelector('[data-upload-progress]');
const multipartInitUrl = uploadForm.dataset.multipartInitUrl;
const multipartPartTemplate = uploadForm.dataset.multipartPartTemplate;
const multipartCompleteTemplate = uploadForm.dataset.multipartCompleteTemplate;
const multipartAbortTemplate = uploadForm.dataset.multipartAbortTemplate;
const createProgressItem = (file) => {
const item = document.createElement('div');
item.className = 'upload-progress-item';
item.dataset.state = 'uploading';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="min-width-0 flex-grow-1">
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-size">${formatBytes(file.size)}</div>
</div>
<div class="upload-status text-end ms-2">Preparing...</div>
</div>
<div class="progress-container">
<div class="progress">
<div class="progress-bar bg-primary" role="progressbar" style="width: 0%"></div>
</div>
<div class="progress-text">
<span class="progress-loaded">0 B</span>
<span class="progress-percent">0%</span>
</div>
</div>
`;
return item;
};
const updateProgressItem = (item, { loaded, total, status, state, error }) => {
if (state) item.dataset.state = state;
const statusEl = item.querySelector('.upload-status');
const progressBar = item.querySelector('.progress-bar');
const progressLoaded = item.querySelector('.progress-loaded');
const progressPercent = item.querySelector('.progress-percent');
if (status) {
statusEl.textContent = status;
statusEl.className = 'upload-status text-end ms-2';
if (state === 'success') statusEl.classList.add('success');
if (state === 'error') statusEl.classList.add('error');
}
if (typeof loaded === 'number' && typeof total === 'number' && total > 0) {
const percent = Math.round((loaded / total) * 100);
progressBar.style.width = `${percent}%`;
progressLoaded.textContent = `${formatBytes(loaded)} / ${formatBytes(total)}`;
progressPercent.textContent = `${percent}%`;
}
if (error) {
const progressContainer = item.querySelector('.progress-container');
if (progressContainer) {
progressContainer.innerHTML = `<div class="text-danger small mt-1">${escapeHtml(error)}</div>`;
}
}
};
const uploadMultipart = async (file, objectKey, metadata, progressItem) => {
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
updateProgressItem(progressItem, { status: 'Initiating...', loaded: 0, total: file.size });
const initResp = await fetch(multipartInitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ object_key: objectKey, metadata })
});
if (!initResp.ok) {
const err = await initResp.json().catch(() => ({}));
throw new Error(err.error || 'Failed to initiate upload');
}
const { upload_id } = await initResp.json();
const partUrl = multipartPartTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
const completeUrl = multipartCompleteTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
const abortUrl = multipartAbortTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
const parts = [];
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
let uploadedBytes = 0;
try {
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
const start = (partNumber - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
updateProgressItem(progressItem, {
status: `Part ${partNumber}/${totalParts}`,
loaded: uploadedBytes,
total: file.size
});
const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken || '' },
body: chunk
});
if (!partResp.ok) {
const err = await partResp.json().catch(() => ({}));
throw new Error(err.error || `Part ${partNumber} failed`);
}
const partData = await partResp.json();
parts.push({ part_number: partNumber, etag: partData.etag });
uploadedBytes += chunk.size;
updateProgressItem(progressItem, {
loaded: uploadedBytes,
total: file.size
});
}
updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size });
const completeResp = await fetch(completeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ parts })
});
if (!completeResp.ok) {
const err = await completeResp.json().catch(() => ({}));
throw new Error(err.error || 'Failed to complete upload');
}
return await completeResp.json();
} catch (err) {
try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch {}
throw err;
}
};
const uploadRegular = async (file, objectKey, metadata, progressItem) => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('object', file);
formData.append('object_key', objectKey);
if (metadata) formData.append('metadata', JSON.stringify(metadata));
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
if (csrfToken) formData.append('csrf_token', csrfToken);
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadForm.action, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
updateProgressItem(progressItem, {
status: 'Uploading...',
loaded: e.loaded,
total: e.total
});
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
if (data.status === 'error') {
reject(new Error(data.message || 'Upload failed'));
} else {
resolve(data);
}
} catch {
resolve({});
}
} else {
try {
const data = JSON.parse(xhr.responseText);
reject(new Error(data.message || `Upload failed (${xhr.status})`));
} catch {
reject(new Error(`Upload failed (${xhr.status})`));
}
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.send(formData);
});
};
const uploadSingleFile = async (file, keyPrefix = '', metadata = null, progressItem = null) => {
const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name;
formData.append('object_key', objectKey);
if (metadata) {
formData.append('metadata', JSON.stringify(metadata));
const shouldUseMultipart = file.size >= MULTIPART_THRESHOLD && multipartInitUrl;
if (!progressItem && uploadProgressStack) {
progressItem = createProgressItem(file);
uploadProgressStack.appendChild(progressItem);
}
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
if (csrfToken) {
formData.append('csrf_token', csrfToken);
}
const response = await fetch(uploadForm.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
try {
let result;
if (shouldUseMultipart) {
updateProgressItem(progressItem, { status: 'Multipart upload...', loaded: 0, total: file.size });
result = await uploadMultipart(file, objectKey, metadata, progressItem);
} else {
updateProgressItem(progressItem, { status: 'Uploading...', loaded: 0, total: file.size });
result = await uploadRegular(file, objectKey, metadata, progressItem);
}
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.status === 'error') {
throw new Error(data.message || 'Upload failed');
updateProgressItem(progressItem, { state: 'success', status: 'Complete', loaded: file.size, total: file.size });
return result;
} catch (err) {
updateProgressItem(progressItem, { state: 'error', status: 'Failed', error: err.message });
throw err;
}
};
const setUploadLockState = (locked) => {
if (uploadDropZone) {
uploadDropZone.classList.toggle('upload-locked', locked);
uploadDropZone.style.pointerEvents = locked ? 'none' : '';
}
if (uploadFileInput) {
uploadFileInput.disabled = locked;
}
return data;
};
const performBulkUpload = async (files) => {
if (isUploading || !files || files.length === 0) return;
isUploading = true;
setUploadLockState(true);
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim();
let metadata = null;
@@ -4256,14 +4471,12 @@
}
isUploading = false;
setUploadLockState(false);
if (successFiles.length > 0) {
if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
window.setTimeout(() => window.location.reload(), 800);
} else {
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
if (uploadFileInput) uploadFileInput.disabled = false;
}
@@ -4271,11 +4484,15 @@
refreshUploadDropLabel();
uploadFileInput.addEventListener('change', () => {
if (isUploading) return;
refreshUploadDropLabel();
updateUploadBtnText();
resetUploadUI();
});
uploadDropZone?.addEventListener('click', () => uploadFileInput?.click());
uploadDropZone?.addEventListener('click', () => {
if (isUploading) return;
uploadFileInput?.click();
});
uploadForm.addEventListener('submit', async (event) => {
const files = uploadFileInput.files;
@@ -4337,6 +4554,7 @@
['dragenter', 'dragover'].forEach((eventName) => {
target.addEventListener(eventName, (event) => {
preventDefaults(event);
if (isUploading) return;
if (highlightClass) {
target.classList.add(highlightClass);
}
@@ -4351,6 +4569,7 @@
});
});
target.addEventListener('drop', (event) => {
if (isUploading) return;
if (!event.dataTransfer?.files?.length || !uploadFileInput) {
return;
}