Compare commits
35 Commits
f60dbaf9c9
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| f0c95ac0a9 | |||
| 0e392e18b4 | |||
| 8996f1ce06 | |||
| 8ff4797041 | |||
| 50fb5aa387 | |||
| cc161bf362 | |||
| 2a0e77a754 | |||
| eb0e435a5a | |||
| 7633007a08 | |||
| de0d869c9f | |||
| fdd068feee | |||
| 66b7677d2c | |||
| 4d90ead816 | |||
| b37a51ed1d | |||
| 0462a7b62e | |||
| 52660570c1 | |||
| 35f61313e0 | |||
| c470cfb576 | |||
| d96955deee | |||
| 85181f0be6 | |||
| d5ca7a8be1 | |||
| 476dc79e42 | |||
| bb6590fc5e | |||
| 899db3421b | |||
| caf01d6ada | |||
| bb366cb4cd | |||
| a2745ff2ee | |||
| 28cb656d94 | |||
| 3c44152fc6 | |||
| 397515edce | |||
| 980fced7e4 | |||
| bae5009ec4 | |||
| 233780617f | |||
| fd8fb21517 | |||
| c6cbe822e1 |
@@ -175,13 +175,21 @@ def handle_app_error(error: AppError) -> Response:
|
|||||||
|
|
||||||
def handle_rate_limit_exceeded(e: RateLimitExceeded) -> Response:
|
def handle_rate_limit_exceeded(e: RateLimitExceeded) -> Response:
|
||||||
g.s3_error_code = "SlowDown"
|
g.s3_error_code = "SlowDown"
|
||||||
|
if request.path.startswith("/ui") or request.path.startswith("/buckets"):
|
||||||
|
wants_json = (
|
||||||
|
request.is_json or
|
||||||
|
request.headers.get("X-Requested-With") == "XMLHttpRequest" or
|
||||||
|
"application/json" in request.accept_mimetypes.values()
|
||||||
|
)
|
||||||
|
if wants_json:
|
||||||
|
return jsonify({"success": False, "error": {"code": "SlowDown", "message": "Please reduce your request rate."}}), 429
|
||||||
error = Element("Error")
|
error = Element("Error")
|
||||||
SubElement(error, "Code").text = "SlowDown"
|
SubElement(error, "Code").text = "SlowDown"
|
||||||
SubElement(error, "Message").text = "Please reduce your request rate."
|
SubElement(error, "Message").text = "Please reduce your request rate."
|
||||||
SubElement(error, "Resource").text = request.path
|
SubElement(error, "Resource").text = request.path
|
||||||
SubElement(error, "RequestId").text = getattr(g, "request_id", "")
|
SubElement(error, "RequestId").text = getattr(g, "request_id", "")
|
||||||
xml_bytes = tostring(error, encoding="utf-8")
|
xml_bytes = tostring(error, encoding="utf-8")
|
||||||
return Response(xml_bytes, status=429, mimetype="application/xml")
|
return Response(xml_bytes, status="429 Too Many Requests", mimetype="application/xml")
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app):
|
def register_error_handlers(app):
|
||||||
|
|||||||
39
app/ui.py
39
app/ui.py
@@ -1063,6 +1063,27 @@ def bulk_delete_objects(bucket_name: str):
|
|||||||
return _respond(False, f"A maximum of {MAX_KEYS} objects can be deleted per request", status_code=400)
|
return _respond(False, f"A maximum of {MAX_KEYS} objects can be deleted per request", status_code=400)
|
||||||
|
|
||||||
unique_keys = list(dict.fromkeys(cleaned))
|
unique_keys = list(dict.fromkeys(cleaned))
|
||||||
|
|
||||||
|
folder_prefixes = [k for k in unique_keys if k.endswith("/")]
|
||||||
|
if folder_prefixes:
|
||||||
|
try:
|
||||||
|
client = get_session_s3_client()
|
||||||
|
for prefix in folder_prefixes:
|
||||||
|
unique_keys.remove(prefix)
|
||||||
|
paginator = client.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix):
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
if obj["Key"] not in unique_keys:
|
||||||
|
unique_keys.append(obj["Key"])
|
||||||
|
except (ClientError, EndpointConnectionError, ConnectionClosedError) as exc:
|
||||||
|
if isinstance(exc, ClientError):
|
||||||
|
err, status = handle_client_error(exc)
|
||||||
|
return _respond(False, err["error"], status_code=status)
|
||||||
|
return _respond(False, "S3 API server is unreachable", status_code=502)
|
||||||
|
|
||||||
|
if not unique_keys:
|
||||||
|
return _respond(False, "No objects found under the selected folders", status_code=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "delete")
|
_authorize_ui(principal, bucket_name, "delete")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
@@ -1093,13 +1114,17 @@ def bulk_delete_objects(bucket_name: str):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
client = get_session_s3_client()
|
client = get_session_s3_client()
|
||||||
objects_to_delete = [{"Key": k} for k in unique_keys]
|
deleted = []
|
||||||
resp = client.delete_objects(
|
errors = []
|
||||||
Bucket=bucket_name,
|
for i in range(0, len(unique_keys), 1000):
|
||||||
Delete={"Objects": objects_to_delete, "Quiet": False},
|
batch = unique_keys[i:i + 1000]
|
||||||
)
|
objects_to_delete = [{"Key": k} for k in batch]
|
||||||
deleted = [d["Key"] for d in resp.get("Deleted", [])]
|
resp = client.delete_objects(
|
||||||
errors = [{"key": e["Key"], "error": e.get("Message", e.get("Code", "Unknown error"))} for e in resp.get("Errors", [])]
|
Bucket=bucket_name,
|
||||||
|
Delete={"Objects": objects_to_delete, "Quiet": False},
|
||||||
|
)
|
||||||
|
deleted.extend(d["Key"] for d in resp.get("Deleted", []))
|
||||||
|
errors.extend({"key": e["Key"], "error": e.get("Message", e.get("Code", "Unknown error"))} for e in resp.get("Errors", []))
|
||||||
for key in deleted:
|
for key in deleted:
|
||||||
_replication_manager().trigger_replication(bucket_name, key, action="delete")
|
_replication_manager().trigger_replication(bucket_name, key, action="delete")
|
||||||
except (ClientError, EndpointConnectionError, ConnectionClosedError) as exc:
|
except (ClientError, EndpointConnectionError, ConnectionClosedError) as exc:
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
const previewMetadataList = document.getElementById('preview-metadata-list');
|
const previewMetadataList = document.getElementById('preview-metadata-list');
|
||||||
const previewPlaceholder = document.getElementById('preview-placeholder');
|
const previewPlaceholder = document.getElementById('preview-placeholder');
|
||||||
const previewPlaceholderDefault = previewPlaceholder ? previewPlaceholder.innerHTML : '';
|
const previewPlaceholderDefault = previewPlaceholder ? previewPlaceholder.innerHTML : '';
|
||||||
|
const previewErrorAlert = document.getElementById('preview-error-alert');
|
||||||
|
const previewDetailsMeta = document.getElementById('preview-details-meta');
|
||||||
const previewImage = document.getElementById('preview-image');
|
const previewImage = document.getElementById('preview-image');
|
||||||
const previewVideo = document.getElementById('preview-video');
|
const previewVideo = document.getElementById('preview-video');
|
||||||
const previewAudio = document.getElementById('preview-audio');
|
const previewAudio = document.getElementById('preview-audio');
|
||||||
@@ -867,6 +869,11 @@
|
|||||||
const checkbox = row.querySelector('[data-folder-select]');
|
const checkbox = row.querySelector('[data-folder-select]');
|
||||||
checkbox?.addEventListener('change', (e) => {
|
checkbox?.addEventListener('change', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (checkbox.checked) {
|
||||||
|
selectedRows.set(folderPath, { key: folderPath, isFolder: true });
|
||||||
|
} else {
|
||||||
|
selectedRows.delete(folderPath);
|
||||||
|
}
|
||||||
const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath));
|
const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath));
|
||||||
folderObjects.forEach(obj => {
|
folderObjects.forEach(obj => {
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
@@ -1351,8 +1358,11 @@
|
|||||||
}
|
}
|
||||||
if (selectAllCheckbox) {
|
if (selectAllCheckbox) {
|
||||||
const filesInView = visibleItems.filter(item => item.type === 'file');
|
const filesInView = visibleItems.filter(item => item.type === 'file');
|
||||||
const total = filesInView.length;
|
const foldersInView = visibleItems.filter(item => item.type === 'folder');
|
||||||
const visibleSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length;
|
const total = filesInView.length + foldersInView.length;
|
||||||
|
const fileSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length;
|
||||||
|
const folderSelectedCount = foldersInView.filter(item => selectedRows.has(item.path)).length;
|
||||||
|
const visibleSelectedCount = fileSelectedCount + folderSelectedCount;
|
||||||
selectAllCheckbox.disabled = total === 0;
|
selectAllCheckbox.disabled = total === 0;
|
||||||
selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
|
selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
|
||||||
selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
|
selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
|
||||||
@@ -1374,8 +1384,12 @@
|
|||||||
const keys = Array.from(selectedRows.keys());
|
const keys = Array.from(selectedRows.keys());
|
||||||
bulkDeleteList.innerHTML = '';
|
bulkDeleteList.innerHTML = '';
|
||||||
if (bulkDeleteCount) {
|
if (bulkDeleteCount) {
|
||||||
const label = keys.length === 1 ? 'object' : 'objects';
|
const folderCount = keys.filter(k => k.endsWith('/')).length;
|
||||||
bulkDeleteCount.textContent = `${keys.length} ${label} selected`;
|
const objectCount = keys.length - folderCount;
|
||||||
|
const parts = [];
|
||||||
|
if (folderCount) parts.push(`${folderCount} folder${folderCount !== 1 ? 's' : ''}`);
|
||||||
|
if (objectCount) parts.push(`${objectCount} object${objectCount !== 1 ? 's' : ''}`);
|
||||||
|
bulkDeleteCount.textContent = `${parts.join(' and ')} selected`;
|
||||||
}
|
}
|
||||||
if (!keys.length) {
|
if (!keys.length) {
|
||||||
const empty = document.createElement('li');
|
const empty = document.createElement('li');
|
||||||
@@ -1978,6 +1992,34 @@
|
|||||||
previewPlaceholder.classList.remove('d-none');
|
previewPlaceholder.classList.remove('d-none');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let previewFailed = false;
|
||||||
|
|
||||||
|
const handlePreviewError = () => {
|
||||||
|
previewFailed = true;
|
||||||
|
if (downloadButton) {
|
||||||
|
downloadButton.classList.add('disabled');
|
||||||
|
downloadButton.removeAttribute('href');
|
||||||
|
}
|
||||||
|
if (presignButton) presignButton.disabled = true;
|
||||||
|
if (generatePresignButton) generatePresignButton.disabled = true;
|
||||||
|
if (previewDetailsMeta) previewDetailsMeta.classList.add('d-none');
|
||||||
|
if (previewMetadata) previewMetadata.classList.add('d-none');
|
||||||
|
const tagsPanel = document.getElementById('preview-tags');
|
||||||
|
if (tagsPanel) tagsPanel.classList.add('d-none');
|
||||||
|
const versionPanel = document.getElementById('version-panel');
|
||||||
|
if (versionPanel) versionPanel.classList.add('d-none');
|
||||||
|
if (previewErrorAlert) {
|
||||||
|
previewErrorAlert.textContent = 'Unable to load object \u2014 it may have been deleted, or the server returned an error.';
|
||||||
|
previewErrorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPreviewError = () => {
|
||||||
|
previewFailed = false;
|
||||||
|
if (previewErrorAlert) previewErrorAlert.classList.add('d-none');
|
||||||
|
if (previewDetailsMeta) previewDetailsMeta.classList.remove('d-none');
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchMetadata(metadataUrl) {
|
async function fetchMetadata(metadataUrl) {
|
||||||
if (!metadataUrl) return null;
|
if (!metadataUrl) return null;
|
||||||
try {
|
try {
|
||||||
@@ -1999,6 +2041,7 @@
|
|||||||
previewPanel.classList.remove('d-none');
|
previewPanel.classList.remove('d-none');
|
||||||
activeRow = row;
|
activeRow = row;
|
||||||
renderMetadata(null);
|
renderMetadata(null);
|
||||||
|
clearPreviewError();
|
||||||
|
|
||||||
previewKey.textContent = row.dataset.key;
|
previewKey.textContent = row.dataset.key;
|
||||||
previewSize.textContent = formatBytes(Number(row.dataset.size));
|
previewSize.textContent = formatBytes(Number(row.dataset.size));
|
||||||
@@ -2024,25 +2067,69 @@
|
|||||||
if (previewUrl && lower.match(/\.(png|jpg|jpeg|gif|webp|svg|ico|bmp)$/)) {
|
if (previewUrl && lower.match(/\.(png|jpg|jpeg|gif|webp|svg|ico|bmp)$/)) {
|
||||||
previewPlaceholder.innerHTML = '<div class="spinner-border spinner-border-sm text-secondary" role="status"></div><div class="small mt-2">Loading preview\u2026</div>';
|
previewPlaceholder.innerHTML = '<div class="spinner-border spinner-border-sm text-secondary" role="status"></div><div class="small mt-2">Loading preview\u2026</div>';
|
||||||
const currentRow = row;
|
const currentRow = row;
|
||||||
previewImage.onload = () => {
|
fetch(previewUrl)
|
||||||
if (activeRow !== currentRow) return;
|
.then((r) => {
|
||||||
previewImage.classList.remove('d-none');
|
if (activeRow !== currentRow) return;
|
||||||
previewPlaceholder.classList.add('d-none');
|
if (!r.ok) {
|
||||||
};
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
previewImage.onerror = () => {
|
handlePreviewError();
|
||||||
if (activeRow !== currentRow) return;
|
return;
|
||||||
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
}
|
||||||
};
|
return r.blob();
|
||||||
previewImage.src = previewUrl;
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
if (!blob || activeRow !== currentRow) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
previewImage.onload = () => {
|
||||||
|
if (activeRow !== currentRow) { URL.revokeObjectURL(url); return; }
|
||||||
|
previewImage.classList.remove('d-none');
|
||||||
|
previewPlaceholder.classList.add('d-none');
|
||||||
|
};
|
||||||
|
previewImage.onerror = () => {
|
||||||
|
if (activeRow !== currentRow) { URL.revokeObjectURL(url); return; }
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
|
};
|
||||||
|
previewImage.src = url;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
|
handlePreviewError();
|
||||||
|
});
|
||||||
} else if (previewUrl && lower.match(/\.(mp4|webm|ogv|mov|avi|mkv)$/)) {
|
} else if (previewUrl && lower.match(/\.(mp4|webm|ogv|mov|avi|mkv)$/)) {
|
||||||
|
const currentRow = row;
|
||||||
|
previewVideo.onerror = () => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewVideo.classList.add('d-none');
|
||||||
|
previewPlaceholder.classList.remove('d-none');
|
||||||
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
|
handlePreviewError();
|
||||||
|
};
|
||||||
previewVideo.src = previewUrl;
|
previewVideo.src = previewUrl;
|
||||||
previewVideo.classList.remove('d-none');
|
previewVideo.classList.remove('d-none');
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
} else if (previewUrl && lower.match(/\.(mp3|wav|flac|ogg|aac|m4a|wma)$/)) {
|
} else if (previewUrl && lower.match(/\.(mp3|wav|flac|ogg|aac|m4a|wma)$/)) {
|
||||||
|
const currentRow = row;
|
||||||
|
previewAudio.onerror = () => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewAudio.classList.add('d-none');
|
||||||
|
previewPlaceholder.classList.remove('d-none');
|
||||||
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
|
handlePreviewError();
|
||||||
|
};
|
||||||
previewAudio.src = previewUrl;
|
previewAudio.src = previewUrl;
|
||||||
previewAudio.classList.remove('d-none');
|
previewAudio.classList.remove('d-none');
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
} else if (previewUrl && lower.match(/\.(pdf)$/)) {
|
} else if (previewUrl && lower.match(/\.(pdf)$/)) {
|
||||||
|
const currentRow = row;
|
||||||
|
previewIframe.onerror = () => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewIframe.classList.add('d-none');
|
||||||
|
previewPlaceholder.classList.remove('d-none');
|
||||||
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
|
handlePreviewError();
|
||||||
|
};
|
||||||
previewIframe.src = previewUrl;
|
previewIframe.src = previewUrl;
|
||||||
previewIframe.style.minHeight = '500px';
|
previewIframe.style.minHeight = '500px';
|
||||||
previewIframe.classList.remove('d-none');
|
previewIframe.classList.remove('d-none');
|
||||||
@@ -2067,14 +2154,17 @@
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (activeRow !== currentRow) return;
|
if (activeRow !== currentRow) return;
|
||||||
previewText.textContent = 'Failed to load preview';
|
previewText.classList.add('d-none');
|
||||||
|
previewPlaceholder.classList.remove('d-none');
|
||||||
|
previewPlaceholder.innerHTML = '<div class="small text-muted">Failed to load preview</div>';
|
||||||
|
handlePreviewError();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataUrl = row.dataset.metadataUrl;
|
const metadataUrl = row.dataset.metadataUrl;
|
||||||
if (metadataUrl) {
|
if (metadataUrl) {
|
||||||
const metadata = await fetchMetadata(metadataUrl);
|
const metadata = await fetchMetadata(metadataUrl);
|
||||||
if (activeRow === row) {
|
if (activeRow === row && !previewFailed) {
|
||||||
renderMetadata(metadata);
|
renderMetadata(metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3172,6 +3262,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const foldersInView = visibleItems.filter(item => item.type === 'folder');
|
||||||
|
foldersInView.forEach(item => {
|
||||||
|
if (shouldSelect) {
|
||||||
|
selectedRows.set(item.path, { key: item.path, isFolder: true });
|
||||||
|
} else {
|
||||||
|
selectedRows.delete(item.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('[data-folder-select]').forEach(cb => {
|
document.querySelectorAll('[data-folder-select]').forEach(cb => {
|
||||||
cb.checked = shouldSelect;
|
cb.checked = shouldSelect;
|
||||||
});
|
});
|
||||||
@@ -3972,6 +4071,10 @@
|
|||||||
|
|
||||||
const loadObjectTags = async (row) => {
|
const loadObjectTags = async (row) => {
|
||||||
if (!row || !previewTagsPanel) return;
|
if (!row || !previewTagsPanel) return;
|
||||||
|
if (previewFailed) {
|
||||||
|
previewTagsPanel.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tagsUrl = row.dataset.tagsUrl;
|
const tagsUrl = row.dataset.tagsUrl;
|
||||||
if (!tagsUrl) {
|
if (!tagsUrl) {
|
||||||
previewTagsPanel.classList.add('d-none');
|
previewTagsPanel.classList.add('d-none');
|
||||||
|
|||||||
@@ -257,7 +257,8 @@
|
|||||||
Share Link
|
Share Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 rounded mb-3" style="background: var(--myfsio-preview-bg);">
|
<div id="preview-error-alert" class="alert alert-warning d-none py-2 px-3 mb-3 small" role="alert"></div>
|
||||||
|
<div id="preview-details-meta" class="p-3 rounded mb-3" style="background: var(--myfsio-preview-bg);">
|
||||||
<dl class="row small mb-0">
|
<dl class="row small mb-0">
|
||||||
<dt class="col-5 text-muted fw-normal">Last modified</dt>
|
<dt class="col-5 text-muted fw-normal">Last modified</dt>
|
||||||
<dd class="col-7 mb-2 fw-medium" id="preview-modified"></dd>
|
<dd class="col-7 mb-2 fw-medium" id="preview-modified"></dd>
|
||||||
|
|||||||
Reference in New Issue
Block a user