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:
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
12
app/ui.py
12
app/ui.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user