MyFSIO v0.2.0 Release #12

Merged
kqjy merged 21 commits from next into main 2026-01-05 15:48:03 +00:00
5 changed files with 417 additions and 68 deletions
Showing only changes of commit b3dce8d13e - Show all commits

View File

@@ -1314,7 +1314,8 @@ def _bucket_list_versions_handler(bucket_name: str) -> Response:
SubElement(version, "VersionId").text = "null" SubElement(version, "VersionId").text = "null"
SubElement(version, "IsLatest").text = "true" SubElement(version, "IsLatest").text = "true"
SubElement(version, "LastModified").text = obj.last_modified.strftime("%Y-%m-%dT%H:%M:%S.000Z") SubElement(version, "LastModified").text = obj.last_modified.strftime("%Y-%m-%dT%H:%M:%S.000Z")
SubElement(version, "ETag").text = f'"{obj.etag}"' if obj.etag:
SubElement(version, "ETag").text = f'"{obj.etag}"'
SubElement(version, "Size").text = str(obj.size) SubElement(version, "Size").text = str(obj.size)
SubElement(version, "StorageClass").text = "STANDARD" SubElement(version, "StorageClass").text = "STANDARD"
@@ -2178,10 +2179,11 @@ def bucket_handler(bucket_name: str) -> Response:
obj_el = SubElement(root, "Contents") obj_el = SubElement(root, "Contents")
SubElement(obj_el, "Key").text = meta.key SubElement(obj_el, "Key").text = meta.key
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat() SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
SubElement(obj_el, "ETag").text = f'"{meta.etag}"' if meta.etag:
SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
SubElement(obj_el, "Size").text = str(meta.size) SubElement(obj_el, "Size").text = str(meta.size)
SubElement(obj_el, "StorageClass").text = "STANDARD" SubElement(obj_el, "StorageClass").text = "STANDARD"
for cp in common_prefixes: for cp in common_prefixes:
cp_el = SubElement(root, "CommonPrefixes") cp_el = SubElement(root, "CommonPrefixes")
SubElement(cp_el, "Prefix").text = cp SubElement(cp_el, "Prefix").text = cp
@@ -2194,15 +2196,16 @@ def bucket_handler(bucket_name: str) -> Response:
SubElement(root, "IsTruncated").text = "true" if is_truncated else "false" SubElement(root, "IsTruncated").text = "true" if is_truncated else "false"
if delimiter: if delimiter:
SubElement(root, "Delimiter").text = delimiter SubElement(root, "Delimiter").text = delimiter
if is_truncated and delimiter and next_marker: if is_truncated and delimiter and next_marker:
SubElement(root, "NextMarker").text = next_marker SubElement(root, "NextMarker").text = next_marker
for meta in objects: for meta in objects:
obj_el = SubElement(root, "Contents") obj_el = SubElement(root, "Contents")
SubElement(obj_el, "Key").text = meta.key SubElement(obj_el, "Key").text = meta.key
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat() SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
SubElement(obj_el, "ETag").text = f'"{meta.etag}"' if meta.etag:
SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
SubElement(obj_el, "Size").text = str(meta.size) SubElement(obj_el, "Size").text = str(meta.size)
for cp in common_prefixes: for cp in common_prefixes:
@@ -2282,7 +2285,8 @@ def object_handler(bucket_name: str, object_key: str):
extra={"bucket": bucket_name, "key": object_key, "size": meta.size}, extra={"bucket": bucket_name, "key": object_key, "size": meta.size},
) )
response = Response(status=200) response = Response(status=200)
response.headers["ETag"] = f'"{meta.etag}"' if meta.etag:
response.headers["ETag"] = f'"{meta.etag}"'
_notifications().emit_object_created( _notifications().emit_object_created(
bucket_name, bucket_name,
@@ -2725,7 +2729,8 @@ def _copy_object(dest_bucket: str, dest_key: str, copy_source: str) -> Response:
root = Element("CopyObjectResult") root = Element("CopyObjectResult")
SubElement(root, "LastModified").text = meta.last_modified.isoformat() SubElement(root, "LastModified").text = meta.last_modified.isoformat()
SubElement(root, "ETag").text = f'"{meta.etag}"' if meta.etag:
SubElement(root, "ETag").text = f'"{meta.etag}"'
return _xml_response(root) return _xml_response(root)
@@ -2947,8 +2952,9 @@ def _complete_multipart_upload(bucket_name: str, object_key: str) -> Response:
SubElement(root, "Location").text = location SubElement(root, "Location").text = location
SubElement(root, "Bucket").text = bucket_name SubElement(root, "Bucket").text = bucket_name
SubElement(root, "Key").text = object_key SubElement(root, "Key").text = object_key
SubElement(root, "ETag").text = f'"{meta.etag}"' if meta.etag:
SubElement(root, "ETag").text = f'"{meta.etag}"'
return _xml_response(root) return _xml_response(root)

View File

@@ -90,7 +90,7 @@ class ObjectMeta:
key: str key: str
size: int size: int
last_modified: datetime last_modified: datetime
etag: str etag: Optional[str] = None
metadata: Optional[Dict[str, str]] = None metadata: Optional[Dict[str, str]] = None
@@ -1079,11 +1079,6 @@ class ObjectStorage:
checksum.update(data) checksum.update(data)
target.write(data) target.write(data)
metadata = manifest.get("metadata")
if metadata:
self._write_metadata(bucket_id, safe_key, metadata)
else:
self._delete_metadata(bucket_id, safe_key)
except BlockingIOError: except BlockingIOError:
raise StorageError("Another upload to this key is in progress") raise StorageError("Another upload to this key is in progress")
finally: finally:
@@ -1097,12 +1092,18 @@ class ObjectStorage:
self._invalidate_bucket_stats_cache(bucket_id) self._invalidate_bucket_stats_cache(bucket_id)
stat = destination.stat() stat = destination.stat()
# Performance: Lazy update - only update the affected key instead of invalidating whole cache etag = checksum.hexdigest()
metadata = manifest.get("metadata")
internal_meta = {"__etag__": etag, "__size__": str(stat.st_size)}
combined_meta = {**internal_meta, **(metadata or {})}
self._write_metadata(bucket_id, safe_key, combined_meta)
obj_meta = ObjectMeta( obj_meta = ObjectMeta(
key=safe_key.as_posix(), key=safe_key.as_posix(),
size=stat.st_size, size=stat.st_size,
last_modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc), last_modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
etag=checksum.hexdigest(), etag=etag,
metadata=metadata, metadata=metadata,
) )
self._update_object_cache_entry(bucket_id, safe_key.as_posix(), obj_meta) self._update_object_cache_entry(bucket_id, safe_key.as_posix(), obj_meta)
@@ -1369,10 +1370,7 @@ class ObjectStorage:
stat = entry.stat() stat = entry.stat()
etag = meta_cache.get(key) etag = meta_cache.get(key)
if not etag:
etag = f'"{stat.st_size}-{int(stat.st_mtime)}"'
objects[key] = ObjectMeta( objects[key] = ObjectMeta(
key=key, key=key,
size=stat.st_size, size=stat.st_size,

View File

@@ -563,6 +563,7 @@ def initiate_multipart_upload(bucket_name: str):
@ui_bp.put("/buckets/<bucket_name>/multipart/<upload_id>/parts") @ui_bp.put("/buckets/<bucket_name>/multipart/<upload_id>/parts")
@limiter.exempt
def upload_multipart_part(bucket_name: str, upload_id: str): def upload_multipart_part(bucket_name: str, upload_id: str):
principal = _current_principal() principal = _current_principal()
try: try:
@@ -606,9 +607,14 @@ def complete_multipart_upload(bucket_name: str, upload_id: str):
normalized.append({"part_number": number, "etag": etag}) normalized.append({"part_number": number, "etag": etag})
try: try:
result = _storage().complete_multipart_upload(bucket_name, upload_id, normalized) result = _storage().complete_multipart_upload(bucket_name, upload_id, normalized)
_replication().trigger_replication(bucket_name, result["key"]) _replication().trigger_replication(bucket_name, result.key)
return jsonify(result) return jsonify({
"key": result.key,
"size": result.size,
"etag": result.etag,
"last_modified": result.last_modified.isoformat() if result.last_modified else None,
})
except StorageError as exc: except StorageError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400

View File

@@ -515,22 +515,77 @@ code {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
max-height: 300px;
overflow-y: auto;
} }
.upload-progress-item { .upload-progress-item {
border: 1px solid var(--myfsio-card-border); border: 1px solid var(--myfsio-card-border);
border-radius: 0.5rem; border-radius: 0.75rem;
background-color: var(--myfsio-card-bg); background-color: var(--myfsio-card-bg);
padding: 0.75rem 0.9rem; padding: 0.875rem 1rem;
transition: border-color 0.2s ease, background-color 0.2s ease; transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.upload-progress-item[data-state='uploading'] {
border-color: rgba(59, 130, 246, 0.4);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
} }
.upload-progress-item[data-state='success'] { .upload-progress-item[data-state='success'] {
border-color: rgba(34, 197, 94, 0.6); border-color: rgba(34, 197, 94, 0.6);
background-color: rgba(34, 197, 94, 0.04);
} }
.upload-progress-item[data-state='error'] { .upload-progress-item[data-state='error'] {
border-color: rgba(239, 68, 68, 0.7); border-color: rgba(239, 68, 68, 0.7);
background-color: rgba(239, 68, 68, 0.04);
}
.upload-progress-item .file-name {
font-weight: 500;
word-break: break-all;
margin-bottom: 0.25rem;
}
.upload-progress-item .file-size {
font-size: 0.75rem;
color: var(--myfsio-muted);
}
.upload-progress-item .upload-status {
font-size: 0.8rem;
color: var(--myfsio-muted);
}
.upload-progress-item .upload-status.success {
color: #16a34a;
}
.upload-progress-item .upload-status.error {
color: #dc2626;
}
.upload-progress-item .progress-container {
margin-top: 0.5rem;
}
.upload-progress-item .progress {
height: 6px;
border-radius: 999px;
overflow: hidden;
}
.upload-progress-item .progress-bar {
transition: width 0.2s ease;
}
.upload-progress-item .progress-text {
font-size: 0.7rem;
color: var(--myfsio-muted);
margin-top: 0.25rem;
display: flex;
justify-content: space-between;
} }
.progress-thin { .progress-thin {
@@ -542,6 +597,14 @@ code {
background-color: rgba(248, 250, 252, 0.15); background-color: rgba(248, 250, 252, 0.15);
} }
[data-theme='dark'] .upload-progress-item .upload-status.success {
color: #4ade80;
}
[data-theme='dark'] .upload-progress-item .upload-status.error {
color: #f87171;
}
#deleteObjectKey { #deleteObjectKey {
word-break: break-all; word-break: break-all;
max-width: 100%; max-width: 100%;
@@ -583,6 +646,20 @@ code {
border-color: #3b82f6; border-color: #3b82f6;
} }
.upload-dropzone.upload-locked {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--myfsio-preview-bg);
}
.upload-dropzone.upload-locked::after {
content: 'Upload in progress...';
display: block;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--myfsio-muted);
}
.metadata-stack .metadata-entry + .metadata-entry { .metadata-stack .metadata-entry + .metadata-entry {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
@@ -620,13 +697,14 @@ code {
} }
.objects-table-container thead th { .objects-table-container thead th {
background-color: #f8f9fa; background-color: var(--myfsio-preview-bg);
border-bottom: 1px solid var(--myfsio-card-border); border-bottom: 2px solid var(--myfsio-card-border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
} }
[data-theme='dark'] .objects-table-container thead th { [data-theme='dark'] .objects-table-container thead th {
background-color: #1e293b; background-color: #1a2234;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
} }
.btn-group form { display: inline; } .btn-group form { display: inline; }
@@ -636,36 +714,77 @@ code {
.table { .table {
color: var(--myfsio-text); color: var(--myfsio-text);
background-color: var(--myfsio-card-bg); background-color: var(--myfsio-card-bg);
border-collapse: separate;
border-spacing: 0;
font-size: 0.9rem;
} }
.table th, .table th,
.table td { .table td {
border-color: var(--myfsio-card-border); border-color: var(--myfsio-card-border);
padding: 0.875rem 1rem;
vertical-align: middle;
}
.table th {
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--myfsio-muted);
border-bottom: 2px solid var(--myfsio-card-border);
}
.table td {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
[data-theme='dark'] .table td {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.table tbody tr:last-child td {
border-bottom: none;
} }
.table-light th { .table-light th {
background-color: rgba(15, 23, 42, 0.04); background-color: var(--myfsio-preview-bg);
} }
[data-theme='dark'] .table-light th { [data-theme='dark'] .table-light th {
background-color: rgba(248, 250, 252, 0.05); background-color: rgba(248, 250, 252, 0.03);
color: var(--myfsio-text); color: var(--myfsio-muted);
}
.table-hover tbody tr {
transition: all 0.15s ease;
} }
.table-hover tbody tr:hover { .table-hover tbody tr:hover {
background-color: var(--myfsio-hover-bg); background-color: var(--myfsio-hover-bg);
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; }
.table-hover tbody tr:hover td {
border-bottom-color: transparent;
} }
.table thead { .table thead {
background-color: rgba(15, 23, 42, 0.04); background-color: var(--myfsio-preview-bg);
color: var(--myfsio-text); color: var(--myfsio-muted);
} }
[data-theme='dark'] .table thead { [data-theme='dark'] .table thead {
background-color: rgba(248, 250, 252, 0.05); background-color: rgba(248, 250, 252, 0.03);
color: var(--myfsio-text); color: var(--myfsio-muted);
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.015);
}
[data-theme='dark'] .table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255, 255, 255, 0.02);
} }
.form-control, .form-control,
@@ -1776,6 +1895,7 @@ body.theme-transitioning * {
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
flex: 1; flex: 1;
justify-content: flex-end;
} }
@media (max-width: 640px) { @media (max-width: 640px) {

View File

@@ -451,6 +451,12 @@
</div> </div>
</div> </div>
<div id="policyErrorDetail" class="text-danger small mt-1 d-none"></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"> <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"> <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"/> <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) => { const applyPolicyPreset = (preset) => {
if (!policyTextarea || !policyMode) return; if (!policyTextarea || !policyMode) return;
const isPresetMode = preset === 'private' || preset === 'public';
if (policyReadonlyHint) {
policyReadonlyHint.classList.toggle('d-none', !isPresetMode);
}
switch (preset) { switch (preset) {
case 'private': case 'private':
setPolicyTextareaState(true); setPolicyTextareaState(true);
@@ -3329,7 +3341,7 @@
policyMode.value = 'delete'; policyMode.value = 'delete';
break; break;
case 'public': case 'public':
setPolicyTextareaState(false); setPolicyTextareaState(true);
policyTextarea.value = publicPolicyTemplate || ''; policyTextarea.value = publicPolicyTemplate || '';
policyMode.value = 'upsert'; policyMode.value = 'upsert';
break; break;
@@ -4160,42 +4172,245 @@
if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = ''; if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = '';
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
if (uploadFileInput) uploadFileInput.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; isUploading = false;
}; };
const uploadSingleFile = async (file, keyPrefix = '', metadata = null) => { const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
const formData = new FormData(); const CHUNK_SIZE = 8 * 1024 * 1024;
formData.append('object', file); 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; const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name;
formData.append('object_key', objectKey); const shouldUseMultipart = file.size >= MULTIPART_THRESHOLD && multipartInitUrl;
if (metadata) {
formData.append('metadata', JSON.stringify(metadata)); if (!progressItem && uploadProgressStack) {
progressItem = createProgressItem(file);
uploadProgressStack.appendChild(progressItem);
} }
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; try {
if (csrfToken) { let result;
formData.append('csrf_token', csrfToken); if (shouldUseMultipart) {
} updateProgressItem(progressItem, { status: 'Multipart upload...', loaded: 0, total: file.size });
result = await uploadMultipart(file, objectKey, metadata, progressItem);
const response = await fetch(uploadForm.action, { } else {
method: 'POST', updateProgressItem(progressItem, { status: 'Uploading...', loaded: 0, total: file.size });
body: formData, result = await uploadRegular(file, objectKey, metadata, progressItem);
headers: {
'X-Requested-With': 'XMLHttpRequest'
} }
}); updateProgressItem(progressItem, { state: 'success', status: 'Complete', loaded: file.size, total: file.size });
return result;
const data = await response.json().catch(() => ({})); } catch (err) {
if (!response.ok || data.status === 'error') { updateProgressItem(progressItem, { state: 'error', status: 'Failed', error: err.message });
throw new Error(data.message || 'Upload failed'); 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) => { const performBulkUpload = async (files) => {
if (isUploading || !files || files.length === 0) return; if (isUploading || !files || files.length === 0) return;
isUploading = true; isUploading = true;
setUploadLockState(true);
const keyPrefix = (uploadKeyPrefix?.value || '').trim(); const keyPrefix = (uploadKeyPrefix?.value || '').trim();
const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim(); const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim();
let metadata = null; let metadata = null;
@@ -4256,14 +4471,12 @@
} }
isUploading = false; isUploading = false;
setUploadLockState(false);
if (successFiles.length > 0) { if (successFiles.length > 0) {
if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...'; if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
window.setTimeout(() => window.location.reload(), 800); window.setTimeout(() => window.location.reload(), 800);
} else { } else {
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
if (uploadFileInput) uploadFileInput.disabled = false; if (uploadFileInput) uploadFileInput.disabled = false;
} }
@@ -4271,11 +4484,15 @@
refreshUploadDropLabel(); refreshUploadDropLabel();
uploadFileInput.addEventListener('change', () => { uploadFileInput.addEventListener('change', () => {
if (isUploading) return;
refreshUploadDropLabel(); refreshUploadDropLabel();
updateUploadBtnText(); updateUploadBtnText();
resetUploadUI(); resetUploadUI();
}); });
uploadDropZone?.addEventListener('click', () => uploadFileInput?.click()); uploadDropZone?.addEventListener('click', () => {
if (isUploading) return;
uploadFileInput?.click();
});
uploadForm.addEventListener('submit', async (event) => { uploadForm.addEventListener('submit', async (event) => {
const files = uploadFileInput.files; const files = uploadFileInput.files;
@@ -4337,6 +4554,7 @@
['dragenter', 'dragover'].forEach((eventName) => { ['dragenter', 'dragover'].forEach((eventName) => {
target.addEventListener(eventName, (event) => { target.addEventListener(eventName, (event) => {
preventDefaults(event); preventDefaults(event);
if (isUploading) return;
if (highlightClass) { if (highlightClass) {
target.classList.add(highlightClass); target.classList.add(highlightClass);
} }
@@ -4351,6 +4569,7 @@
}); });
}); });
target.addEventListener('drop', (event) => { target.addEventListener('drop', (event) => {
if (isUploading) return;
if (!event.dataTransfer?.files?.length || !uploadFileInput) { if (!event.dataTransfer?.files?.length || !uploadFileInput) {
return; return;
} }