MyFSIO v0.2.8 Release #20
@@ -36,11 +36,11 @@ class GzipMiddleware:
|
|||||||
content_type = None
|
content_type = None
|
||||||
content_length = None
|
content_length = None
|
||||||
should_compress = False
|
should_compress = False
|
||||||
is_streaming = False
|
passthrough = False
|
||||||
exc_info_holder = [None]
|
exc_info_holder = [None]
|
||||||
|
|
||||||
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
||||||
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress, is_streaming
|
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress, passthrough
|
||||||
response_started = True
|
response_started = True
|
||||||
status_code = int(status.split(' ', 1)[0])
|
status_code = int(status.split(' ', 1)[0])
|
||||||
response_headers = list(headers)
|
response_headers = list(headers)
|
||||||
@@ -51,23 +51,29 @@ class GzipMiddleware:
|
|||||||
if name_lower == 'content-type':
|
if name_lower == 'content-type':
|
||||||
content_type = value.split(';')[0].strip().lower()
|
content_type = value.split(';')[0].strip().lower()
|
||||||
elif name_lower == 'content-length':
|
elif name_lower == 'content-length':
|
||||||
content_length = int(value)
|
try:
|
||||||
|
content_length = int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
elif name_lower == 'content-encoding':
|
elif name_lower == 'content-encoding':
|
||||||
should_compress = False
|
passthrough = True
|
||||||
return start_response(status, headers, exc_info)
|
return start_response(status, headers, exc_info)
|
||||||
elif name_lower == 'x-stream-response':
|
elif name_lower == 'x-stream-response':
|
||||||
is_streaming = True
|
passthrough = True
|
||||||
return start_response(status, headers, exc_info)
|
return start_response(status, headers, exc_info)
|
||||||
|
|
||||||
if content_type and content_type in COMPRESSIBLE_MIMES:
|
if content_type and content_type in COMPRESSIBLE_MIMES:
|
||||||
if content_length is None or content_length >= self.min_size:
|
if content_length is None or content_length >= self.min_size:
|
||||||
should_compress = True
|
should_compress = True
|
||||||
|
else:
|
||||||
|
passthrough = True
|
||||||
|
return start_response(status, headers, exc_info)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
app_iter = self.app(environ, custom_start_response)
|
app_iter = self.app(environ, custom_start_response)
|
||||||
|
|
||||||
if is_streaming:
|
if passthrough:
|
||||||
return app_iter
|
return app_iter
|
||||||
|
|
||||||
response_body = b''.join(app_iter)
|
response_body = b''.join(app_iter)
|
||||||
|
|||||||
@@ -2781,7 +2781,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
file_size = stat.st_size
|
file_size = stat.st_size
|
||||||
etag = storage._compute_etag(path)
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -2829,7 +2829,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
etag = storage._compute_etag(path)
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
180
app/storage.py
180
app/storage.py
@@ -188,6 +188,7 @@ class ObjectStorage:
|
|||||||
self._object_cache_max_size = object_cache_max_size
|
self._object_cache_max_size = object_cache_max_size
|
||||||
self._object_key_max_length_bytes = object_key_max_length_bytes
|
self._object_key_max_length_bytes = object_key_max_length_bytes
|
||||||
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
|
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
|
||||||
|
self._meta_index_locks: Dict[str, threading.Lock] = {}
|
||||||
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
||||||
|
|
||||||
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
||||||
@@ -816,6 +817,10 @@ class ObjectStorage:
|
|||||||
if not object_path.exists():
|
if not object_path.exists():
|
||||||
raise ObjectNotFoundError("Object does not exist")
|
raise ObjectNotFoundError("Object does not exist")
|
||||||
|
|
||||||
|
entry = self._read_index_entry(bucket_path.name, safe_key)
|
||||||
|
if entry is not None:
|
||||||
|
tags = entry.get("tags")
|
||||||
|
return tags if isinstance(tags, list) else []
|
||||||
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
|
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
|
||||||
if not meta_file.exists():
|
if not meta_file.exists():
|
||||||
continue
|
continue
|
||||||
@@ -839,30 +844,31 @@ class ObjectStorage:
|
|||||||
if not object_path.exists():
|
if not object_path.exists():
|
||||||
raise ObjectNotFoundError("Object does not exist")
|
raise ObjectNotFoundError("Object does not exist")
|
||||||
|
|
||||||
meta_file = self._metadata_file(bucket_path.name, safe_key)
|
bucket_id = bucket_path.name
|
||||||
|
existing_entry = self._read_index_entry(bucket_id, safe_key) or {}
|
||||||
existing_payload: Dict[str, Any] = {}
|
if not existing_entry:
|
||||||
if meta_file.exists():
|
meta_file = self._metadata_file(bucket_id, safe_key)
|
||||||
try:
|
if meta_file.exists():
|
||||||
existing_payload = json.loads(meta_file.read_text(encoding="utf-8"))
|
try:
|
||||||
except (OSError, json.JSONDecodeError):
|
existing_entry = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
pass
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
if tags:
|
if tags:
|
||||||
existing_payload["tags"] = tags
|
existing_entry["tags"] = tags
|
||||||
else:
|
else:
|
||||||
existing_payload.pop("tags", None)
|
existing_entry.pop("tags", None)
|
||||||
|
|
||||||
if existing_payload.get("metadata") or existing_payload.get("tags"):
|
if existing_entry.get("metadata") or existing_entry.get("tags"):
|
||||||
meta_file.parent.mkdir(parents=True, exist_ok=True)
|
self._write_index_entry(bucket_id, safe_key, existing_entry)
|
||||||
meta_file.write_text(json.dumps(existing_payload), encoding="utf-8")
|
else:
|
||||||
elif meta_file.exists():
|
self._delete_index_entry(bucket_id, safe_key)
|
||||||
meta_file.unlink()
|
old_meta = self._metadata_file(bucket_id, safe_key)
|
||||||
parent = meta_file.parent
|
try:
|
||||||
meta_root = self._bucket_meta_root(bucket_path.name)
|
if old_meta.exists():
|
||||||
while parent != meta_root and parent.exists() and not any(parent.iterdir()):
|
old_meta.unlink()
|
||||||
parent.rmdir()
|
except OSError:
|
||||||
parent = parent.parent
|
pass
|
||||||
|
|
||||||
def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
|
def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
|
||||||
"""Delete all tags from an object."""
|
"""Delete all tags from an object."""
|
||||||
@@ -1529,7 +1535,7 @@ class ObjectStorage:
|
|||||||
if entry.is_dir(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
if check_newer(entry.path):
|
if check_newer(entry.path):
|
||||||
return True
|
return True
|
||||||
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
|
elif entry.is_file(follow_symlinks=False) and (entry.name.endswith('.meta.json') or entry.name == '_index.json'):
|
||||||
if entry.stat().st_mtime > index_mtime:
|
if entry.stat().st_mtime > index_mtime:
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1543,22 +1549,50 @@ class ObjectStorage:
|
|||||||
meta_str = str(meta_root)
|
meta_str = str(meta_root)
|
||||||
meta_len = len(meta_str) + 1
|
meta_len = len(meta_str) + 1
|
||||||
meta_files: list[tuple[str, str]] = []
|
meta_files: list[tuple[str, str]] = []
|
||||||
|
index_files: list[str] = []
|
||||||
|
|
||||||
def collect_meta_files(dir_path: str) -> None:
|
def collect_meta_files(dir_path: str) -> None:
|
||||||
try:
|
try:
|
||||||
with os.scandir(dir_path) as it:
|
with os.scandir(dir_path) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
if entry.is_dir(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
collect_meta_files(entry.path)
|
collect_meta_files(entry.path)
|
||||||
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
|
elif entry.is_file(follow_symlinks=False):
|
||||||
rel = entry.path[meta_len:]
|
if entry.name == '_index.json':
|
||||||
key = rel[:-10].replace(os.sep, '/')
|
index_files.append(entry.path)
|
||||||
meta_files.append((key, entry.path))
|
elif entry.name.endswith('.meta.json'):
|
||||||
|
rel = entry.path[meta_len:]
|
||||||
|
key = rel[:-10].replace(os.sep, '/')
|
||||||
|
meta_files.append((key, entry.path))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
collect_meta_files(meta_str)
|
collect_meta_files(meta_str)
|
||||||
|
|
||||||
|
meta_cache = {}
|
||||||
|
|
||||||
|
for idx_path in index_files:
|
||||||
|
try:
|
||||||
|
with open(idx_path, 'r', encoding='utf-8') as f:
|
||||||
|
idx_data = json.load(f)
|
||||||
|
rel_dir = idx_path[meta_len:]
|
||||||
|
rel_dir = rel_dir.replace(os.sep, '/')
|
||||||
|
if rel_dir.endswith('/_index.json'):
|
||||||
|
dir_prefix = rel_dir[:-len('/_index.json')]
|
||||||
|
else:
|
||||||
|
dir_prefix = ''
|
||||||
|
for entry_name, entry_data in idx_data.items():
|
||||||
|
if dir_prefix:
|
||||||
|
key = f"{dir_prefix}/{entry_name}"
|
||||||
|
else:
|
||||||
|
key = entry_name
|
||||||
|
meta = entry_data.get("metadata", {})
|
||||||
|
etag = meta.get("__etag__")
|
||||||
|
if etag:
|
||||||
|
meta_cache[key] = etag
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
|
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
|
||||||
key, path = item
|
key, path = item
|
||||||
try:
|
try:
|
||||||
@@ -1575,15 +1609,16 @@ class ObjectStorage:
|
|||||||
return key, None
|
return key, None
|
||||||
except (OSError, UnicodeDecodeError):
|
except (OSError, UnicodeDecodeError):
|
||||||
return key, None
|
return key, None
|
||||||
|
|
||||||
if meta_files:
|
legacy_meta_files = [(k, p) for k, p in meta_files if k not in meta_cache]
|
||||||
meta_cache = {}
|
if legacy_meta_files:
|
||||||
max_workers = min((os.cpu_count() or 4) * 2, len(meta_files), 16)
|
max_workers = min((os.cpu_count() or 4) * 2, len(legacy_meta_files), 16)
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
for key, etag in executor.map(read_meta_file, meta_files):
|
for key, etag in executor.map(read_meta_file, legacy_meta_files):
|
||||||
if etag:
|
if etag:
|
||||||
meta_cache[key] = etag
|
meta_cache[key] = etag
|
||||||
|
|
||||||
|
if meta_cache:
|
||||||
try:
|
try:
|
||||||
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
@@ -1833,6 +1868,64 @@ class ObjectStorage:
|
|||||||
meta_rel = Path(key.as_posix() + ".meta.json")
|
meta_rel = Path(key.as_posix() + ".meta.json")
|
||||||
return meta_root / meta_rel
|
return meta_root / meta_rel
|
||||||
|
|
||||||
|
def _index_file_for_key(self, bucket_name: str, key: Path) -> tuple[Path, str]:
|
||||||
|
meta_root = self._bucket_meta_root(bucket_name)
|
||||||
|
parent = key.parent
|
||||||
|
entry_name = key.name
|
||||||
|
if parent == Path("."):
|
||||||
|
return meta_root / "_index.json", entry_name
|
||||||
|
return meta_root / parent / "_index.json", entry_name
|
||||||
|
|
||||||
|
def _get_meta_index_lock(self, index_path: str) -> threading.Lock:
|
||||||
|
with self._cache_lock:
|
||||||
|
if index_path not in self._meta_index_locks:
|
||||||
|
self._meta_index_locks[index_path] = threading.Lock()
|
||||||
|
return self._meta_index_locks[index_path]
|
||||||
|
|
||||||
|
def _read_index_entry(self, bucket_name: str, key: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
|
if not index_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
return index_data.get(entry_name)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_index_entry(self, bucket_name: str, key: Path, entry: Dict[str, Any]) -> None:
|
||||||
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
|
lock = self._get_meta_index_lock(str(index_path))
|
||||||
|
with lock:
|
||||||
|
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
index_data: Dict[str, Any] = {}
|
||||||
|
if index_path.exists():
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
index_data[entry_name] = entry
|
||||||
|
index_path.write_text(json.dumps(index_data), encoding="utf-8")
|
||||||
|
|
||||||
|
def _delete_index_entry(self, bucket_name: str, key: Path) -> None:
|
||||||
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
|
if not index_path.exists():
|
||||||
|
return
|
||||||
|
lock = self._get_meta_index_lock(str(index_path))
|
||||||
|
with lock:
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
if entry_name in index_data:
|
||||||
|
del index_data[entry_name]
|
||||||
|
if index_data:
|
||||||
|
index_path.write_text(json.dumps(index_data), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
index_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return None
|
return None
|
||||||
@@ -1844,9 +1937,13 @@ class ObjectStorage:
|
|||||||
if not clean:
|
if not clean:
|
||||||
self._delete_metadata(bucket_name, key)
|
self._delete_metadata(bucket_name, key)
|
||||||
return
|
return
|
||||||
meta_file = self._metadata_file(bucket_name, key)
|
self._write_index_entry(bucket_name, key, {"metadata": clean})
|
||||||
meta_file.parent.mkdir(parents=True, exist_ok=True)
|
old_meta = self._metadata_file(bucket_name, key)
|
||||||
meta_file.write_text(json.dumps({"metadata": clean}), encoding="utf-8")
|
try:
|
||||||
|
if old_meta.exists():
|
||||||
|
old_meta.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None:
|
def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
@@ -1873,6 +1970,10 @@ class ObjectStorage:
|
|||||||
manifest_path.write_text(json.dumps(record), encoding="utf-8")
|
manifest_path.write_text(json.dumps(record), encoding="utf-8")
|
||||||
|
|
||||||
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
|
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
|
||||||
|
entry = self._read_index_entry(bucket_name, key)
|
||||||
|
if entry is not None:
|
||||||
|
data = entry.get("metadata")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)):
|
for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)):
|
||||||
if not meta_file.exists():
|
if not meta_file.exists():
|
||||||
continue
|
continue
|
||||||
@@ -1903,6 +2004,7 @@ class ObjectStorage:
|
|||||||
raise StorageError(message) from last_error
|
raise StorageError(message) from last_error
|
||||||
|
|
||||||
def _delete_metadata(self, bucket_name: str, key: Path) -> None:
|
def _delete_metadata(self, bucket_name: str, key: Path) -> None:
|
||||||
|
self._delete_index_entry(bucket_name, key)
|
||||||
locations = (
|
locations = (
|
||||||
(self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)),
|
(self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)),
|
||||||
(self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)),
|
(self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)),
|
||||||
|
|||||||
@@ -1162,7 +1162,9 @@ def object_preview(bucket_name: str, object_key: str) -> Response:
|
|||||||
"text/html", "text/xml", "application/xhtml+xml",
|
"text/html", "text/xml", "application/xhtml+xml",
|
||||||
"application/xml", "image/svg+xml",
|
"application/xml", "image/svg+xml",
|
||||||
}
|
}
|
||||||
force_download = content_type.split(";")[0].strip().lower() in _DANGEROUS_TYPES
|
base_ct = content_type.split(";")[0].strip().lower()
|
||||||
|
if not download and base_ct in _DANGEROUS_TYPES:
|
||||||
|
content_type = "text/plain; charset=utf-8"
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
try:
|
try:
|
||||||
@@ -1181,7 +1183,7 @@ def object_preview(bucket_name: str, object_key: str) -> Response:
|
|||||||
headers["Content-Length"] = str(content_length)
|
headers["Content-Length"] = str(content_length)
|
||||||
if content_range:
|
if content_range:
|
||||||
headers["Content-Range"] = content_range
|
headers["Content-Range"] = content_range
|
||||||
disposition = "attachment" if download or force_download else "inline"
|
disposition = "attachment" if download else "inline"
|
||||||
if ascii_safe:
|
if ascii_safe:
|
||||||
headers["Content-Disposition"] = f'{disposition}; filename="{safe_filename}"'
|
headers["Content-Disposition"] = f'{disposition}; filename="{safe_filename}"'
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1288,6 +1288,20 @@ html.sidebar-will-collapse .sidebar-user {
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#preview-text {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 4;
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-progress-stack {
|
.upload-progress-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
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');
|
||||||
|
const previewText = document.getElementById('preview-text');
|
||||||
const previewIframe = document.getElementById('preview-iframe');
|
const previewIframe = document.getElementById('preview-iframe');
|
||||||
const downloadButton = document.getElementById('downloadButton');
|
const downloadButton = document.getElementById('downloadButton');
|
||||||
const presignButton = document.getElementById('presignButton');
|
const presignButton = document.getElementById('presignButton');
|
||||||
@@ -1895,6 +1896,10 @@
|
|||||||
el.setAttribute('src', 'about:blank');
|
el.setAttribute('src', 'about:blank');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (previewText) {
|
||||||
|
previewText.classList.add('d-none');
|
||||||
|
previewText.textContent = '';
|
||||||
|
}
|
||||||
previewPlaceholder.classList.remove('d-none');
|
previewPlaceholder.classList.remove('d-none');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1958,11 +1963,28 @@
|
|||||||
previewIframe.style.minHeight = '500px';
|
previewIframe.style.minHeight = '500px';
|
||||||
previewIframe.classList.remove('d-none');
|
previewIframe.classList.remove('d-none');
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
} else if (previewUrl && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat)$/)) {
|
} else if (previewUrl && previewText && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat|rs|go|rb|php|sql|r|swift|kt|scala|pl|lua|zig|ex|exs|hs|erl|ps1|psm1|psd1|fish|zsh|env|properties|gradle|makefile|dockerfile|vagrantfile|gitignore|gitattributes|editorconfig|eslintrc|prettierrc)$/)) {
|
||||||
previewIframe.src = previewUrl;
|
previewText.textContent = 'Loading\u2026';
|
||||||
previewIframe.style.minHeight = '200px';
|
previewText.classList.remove('d-none');
|
||||||
previewIframe.classList.remove('d-none');
|
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
|
const currentRow = row;
|
||||||
|
fetch(previewUrl)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
const len = parseInt(r.headers.get('Content-Length') || '0', 10);
|
||||||
|
if (len > 512 * 1024) {
|
||||||
|
return r.text().then((t) => t.slice(0, 512 * 1024) + '\n\n--- Truncated (file too large for preview) ---');
|
||||||
|
}
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewText.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewText.textContent = 'Failed to load preview';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataUrl = row.dataset.metadataUrl;
|
const metadataUrl = row.dataset.metadataUrl;
|
||||||
|
|||||||
@@ -321,7 +321,8 @@
|
|||||||
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
||||||
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
||||||
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
||||||
<iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
|
<pre id="preview-text" class="w-100 d-none m-0"></pre>
|
||||||
|
<iframe id="preview-iframe" class="w-100 d-none" style="min-height: 200px;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user