release: archivebox 0.9.31rc43

This commit is contained in:
Nick Sweeting 2026-05-24 23:51:46 -07:00
parent 28b890a2c7
commit 082bb50eda
No known key found for this signature in database
5 changed files with 180 additions and 49 deletions

View File

@ -764,9 +764,14 @@ def _serve_responses_path(request, responses_root: Path, rel_path: str, show_ind
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
request_config = getattr(request, "archivebox_config", None)
if request_config is None:
request_config = get_config(resolve_plugins=False)
request.archivebox_config = request_config
snapshot._runtime_config = request_config
rel_path = path or ""
is_directory_request = bool(path) and path.endswith("/")
show_indexes = bool(request.GET.get("files")) or (get_config().USES_SUBDOMAIN_ROUTING and is_directory_request)
show_indexes = bool(request.GET.get("files")) or (request_config.USES_SUBDOMAIN_ROUTING and is_directory_request)
if not show_indexes and (not rel_path or rel_path == "index.html"):
return SnapshotView.render_live_index(request, snapshot)
@ -847,16 +852,20 @@ class SnapshotHostView(View):
"""Serve snapshot directory contents on <snapshot-subdomain>.<listen_host>/<path>."""
def get(self, request, snapshot_id: str, path: str = ""):
if not request.user.is_authenticated and not get_config().PUBLIC_SNAPSHOTS:
request_config = getattr(request, "archivebox_config", None)
if request_config is None:
request_config = get_config(resolve_plugins=False)
request.archivebox_config = request_config
if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
return _admin_login_redirect_or_forbidden(request)
snapshot = _find_snapshot_by_ref(snapshot_id)
if not snapshot:
raise Http404
canonical_host = get_snapshot_host(str(snapshot.id))
canonical_host = get_snapshot_host(str(snapshot.id), config=request_config)
if not host_matches(request.get_host(), canonical_host):
target = build_snapshot_url(str(snapshot.id), path, request=request)
target = build_snapshot_url(str(snapshot.id), path, request=request, config=request_config)
if request.META.get("QUERY_STRING"):
target = f"{target}?{request.META['QUERY_STRING']}"
return redirect(target)
@ -868,7 +877,11 @@ class SnapshotReplayView(View):
"""Serve snapshot directory contents on a one-domain replay path."""
def get(self, request, snapshot_id: str, path: str = ""):
if not request.user.is_authenticated and not get_config().PUBLIC_SNAPSHOTS:
request_config = getattr(request, "archivebox_config", None)
if request_config is None:
request_config = get_config(resolve_plugins=False)
request.archivebox_config = request_config
if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
return _admin_login_redirect_or_forbidden(request)
snapshot = _find_snapshot_by_ref(snapshot_id)
@ -882,7 +895,11 @@ class OriginalDomainHostView(View):
"""Serve responses from the most recent snapshot when using <domain>.<listen_host>/<path>."""
def get(self, request, domain: str, path: str = ""):
if not request.user.is_authenticated and not get_config().PUBLIC_SNAPSHOTS:
request_config = getattr(request, "archivebox_config", None)
if request_config is None:
request_config = get_config(resolve_plugins=False)
request.archivebox_config = request_config
if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
return _admin_login_redirect_or_forbidden(request)
return _serve_original_domain_replay(request, domain, path)
@ -891,7 +908,11 @@ class OriginalDomainReplayView(View):
"""Serve original-domain replay content on a one-domain replay path."""
def get(self, request, domain: str, path: str = ""):
if not request.user.is_authenticated and not get_config().PUBLIC_SNAPSHOTS:
request_config = getattr(request, "archivebox_config", None)
if request_config is None:
request_config = get_config(resolve_plugins=False)
request.archivebox_config = request_config
if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
return _admin_login_redirect_or_forbidden(request)
return _serve_original_domain_replay(request, domain, path)

View File

@ -61,8 +61,38 @@ def _hash_for_path(document_root: Path, rel_path: str) -> str | None:
return file_map.get(rel_path)
def _resolve_archive_path(document_root: str | Path, rel_path: str) -> tuple[Path, str]:
rel_path = posixpath.normpath(rel_path).lstrip("/") if rel_path else ""
fullpath = Path(safe_join(document_root, rel_path))
if os.access(fullpath, os.R_OK):
return fullpath, rel_path
root = Path(document_root)
current = root
resolved_parts: list[str] = []
for part in Path(rel_path).parts:
exact = current / part
if os.access(exact, os.R_OK):
current = exact
resolved_parts.append(part)
continue
folded_part = part.casefold()
try:
match = next((child for child in current.iterdir() if child.name.casefold() == folded_part), None)
except OSError:
match = None
if match is None:
return fullpath, rel_path
current = match
resolved_parts.append(match.name)
return current, posixpath.join(*resolved_parts) if resolved_parts else ""
def _cache_policy(config=None, **config_kwargs) -> str:
config = config or get_config(**config_kwargs)
config = config or get_config(resolve_plugins=False, **config_kwargs)
return "public" if config.PUBLIC_SNAPSHOTS else "private"
@ -139,6 +169,7 @@ def _build_directory_zip_response(
*,
is_archive_replay: bool,
use_async_stream: bool,
config=None,
) -> StreamingHttpResponse:
root_name = _safe_zip_stem(fullpath.name or Path(path).name or "archivebox")
sentinel = object()
@ -210,7 +241,7 @@ def _build_directory_zip_response(
content_type="application/zip",
)
response.headers["Content-Disposition"] = f'attachment; filename="{root_name}.zip"'
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Last-Modified"] = http_date(fullpath.stat().st_mtime)
response.headers["X-Accel-Buffering"] = "no"
return _apply_archive_replay_headers(
@ -218,6 +249,7 @@ def _build_directory_zip_response(
fullpath=fullpath,
content_type="application/zip",
is_archive_replay=is_archive_replay,
config=config,
)
@ -640,7 +672,7 @@ def _apply_archive_replay_headers(
return response
response.headers.setdefault("X-Content-Type-Options", "nosniff")
config = config or get_config(**config_kwargs)
config = config or get_config(resolve_plugins=False, **config_kwargs)
response.headers.setdefault("X-ArchiveBox-Security-Mode", config.SERVER_SECURITY_MODE)
if config.SHOULD_NEUTER_RISKY_REPLAY and _is_risky_replay_document(fullpath, content_type):
@ -671,8 +703,11 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
https://github.com/satchamo/django/commit/2ce75c5c4bee2a858c0214d136bfcd351fcde11d
"""
assert document_root
path = posixpath.normpath(path).lstrip("/")
fullpath = Path(safe_join(document_root, path))
config = getattr(request, "archivebox_config", None)
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
fullpath, path = _resolve_archive_path(document_root, path)
if os.access(fullpath, os.R_OK) and fullpath.is_dir():
if request.GET.get("download") == "zip" and show_indexes:
return _build_directory_zip_response(
@ -680,10 +715,19 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
path,
is_archive_replay=is_archive_replay,
use_async_stream=hasattr(request, "scope"),
config=config,
)
if show_indexes:
response = _render_directory_index(request, path, fullpath)
return _apply_archive_replay_headers(response, fullpath=fullpath, content_type="text/html", is_archive_replay=is_archive_replay)
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Last-Modified"] = http_date(fullpath.stat().st_mtime)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
content_type="text/html",
is_archive_replay=is_archive_replay,
config=config,
)
raise Http404(_("Directory indexes are not allowed here."))
if not os.access(fullpath, os.R_OK):
raise Http404(_("%(path)s” does not exist") % {"path": fullpath})
@ -704,9 +748,15 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
if etag in inm_list or etag.strip('"') in [i.strip('"') for i in inm_list]:
not_modified = HttpResponseNotModified()
not_modified.headers["ETag"] = etag
not_modified.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
not_modified.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
not_modified.headers["Last-Modified"] = http_date(statobj.st_mtime)
return _apply_archive_replay_headers(not_modified, fullpath=fullpath, content_type="", is_archive_replay=is_archive_replay)
return _apply_archive_replay_headers(
not_modified,
fullpath=fullpath,
content_type="",
is_archive_replay=is_archive_replay,
config=config,
)
content_type, encoding = mimetypes.guess_type(str(fullpath))
content_type = content_type or "application/octet-stream"
@ -739,6 +789,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath=fullpath,
content_type=content_type,
is_archive_replay=is_archive_replay,
config=config,
)
# Wrap text-like outputs in HTML when explicitly requested for iframe previewing.
@ -752,9 +803,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if encoding:
response.headers["Content-Encoding"] = encoding
@ -763,6 +814,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath=fullpath,
content_type="text/html; charset=utf-8",
is_archive_replay=is_archive_replay,
config=config,
)
except Exception:
pass
@ -779,9 +831,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if encoding:
response.headers["Content-Encoding"] = encoding
@ -790,6 +842,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath=fullpath,
content_type="text/html; charset=utf-8",
is_archive_replay=is_archive_replay,
config=config,
)
except Exception:
pass
@ -806,9 +859,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.stem}.html"'
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Content-Security-Policy"] = (
@ -846,9 +899,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if encoding:
response.headers["Content-Encoding"] = encoding
@ -857,15 +910,16 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath=fullpath,
content_type="text/html; charset=utf-8",
is_archive_replay=is_archive_replay,
config=config,
)
if escaped_count and escaped_count > tag_count * 2:
response = HttpResponse(decoded, content_type=content_type)
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if encoding:
response.headers["Content-Encoding"] = encoding
@ -874,6 +928,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath=fullpath,
content_type=content_type,
is_archive_replay=is_archive_replay,
config=config,
)
except Exception:
pass
@ -884,9 +939,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy()}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
if is_text_like:
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if content_type.startswith("image/"):
@ -918,7 +973,13 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.status_code = 206
if encoding:
response.headers["Content-Encoding"] = encoding
return _apply_archive_replay_headers(response, fullpath=fullpath, content_type=content_type, is_archive_replay=is_archive_replay)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
content_type=content_type,
is_archive_replay=is_archive_replay,
config=config,
)
def serve_static(request, path, **kwargs):

View File

@ -15,7 +15,11 @@
margin: 0;
padding: 0;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
body {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
a { color: inherit; text-decoration: none; }
a:hover { text-decoration: underline; }
.container-fluid { width: 100%; margin: 0 auto; padding: 0 12px; }
@ -33,6 +37,8 @@
.alert { padding: 6px 10px; border-radius: 6px; background: #f5f5f5; color: #333; }
header {
background-color: #aa1e55;
order: 1;
flex: 0 0 auto;
}
small {
font-weight: 200;
@ -436,6 +442,8 @@
height: calc(100vh - 210px);
border-top: 3px solid #aa1e55;
overflow: hidden;
order: 2;
flex: 0 0 auto;
}
#main-frame-wrapper iframe {
width: 100%;
@ -963,6 +971,48 @@
</style>
</head>
<body>
<div id="main-frame-wrapper" class="full-page-wrapper">
<iframe id="main-frame"
sandbox="allow-same-origin allow-top-navigation-by-user-activation allow-scripts allow-forms"
class="full-page-iframe"
src="about:blank"
data-default-src="{% if best_result.path %}{% snapshot_preview_url snapshot best_result.path %}{% else %}{% snapshot_base_url snapshot %}/?files=1{% endif %}"
name="preview"
loading="eager"
fetchpriority="high"></iframe>
</div>
<script>
(() => {
const frame = document.getElementById('main-frame')
if (!frame) return
const snapshotBaseUrlEarly = "{% snapshot_base_url snapshot %}"
const snapshotFilesUrlEarly = `${snapshotBaseUrlEarly}/?files=1`
const defaultSrc = frame.dataset.defaultSrc || snapshotFilesUrlEarly
const rawHash = window.location.hash ? window.location.hash.slice(1) : ''
function resolveEarlyPreviewUrl(raw) {
if (!raw) return defaultSrc
let value = raw
try {
value = decodeURIComponent(value)
} catch (err) {}
if (value === 'all') return snapshotFilesUrlEarly
if (value.startsWith('http://') || value.startsWith('https://')) return value
if (value.startsWith('//')) return window.location.protocol + value
value = value.replace(/^\/+/, '')
return `${snapshotBaseUrlEarly}/${value}`
}
const target = resolveEarlyPreviewUrl(rawHash)
if (target) {
if (target.endsWith('.pdf')) {
frame.removeAttribute('sandbox')
}
frame.src = target
frame.dataset.initialSrc = target
}
})()
</script>
<header>
<div class="header-top">
<div class="header-nav">
@ -1174,19 +1224,6 @@
</div>
</header>
<div id="main-frame-wrapper" class="full-page-wrapper">
<iframe id="main-frame"
sandbox="allow-same-origin allow-top-navigation-by-user-activation allow-scripts allow-forms"
class="full-page-iframe"
src="about:blank"
data-default-src="{% if best_result.path %}{% snapshot_preview_url snapshot best_result.path %}{% else %}{% snapshot_base_url snapshot %}/?files=1{% endif %}"
name="preview"
loading="eager"
fetchpriority="high"></iframe>
</div>
<script src="{% static 'jquery.min.js' %}" type="text/javascript"></script>
<script>
@ -1398,7 +1435,17 @@
jQuery('.selected-card').removeClass('selected-card')
jQuery(card).closest('.thumb-card').addClass('selected-card')
const iframe_elem = ensureMainFrame(true)
const nextSrc = isSnapshotRootPreview(target) ? snapshotFilesUrl : target
const existingFrame = document.getElementById('main-frame')
let currentSrc = ''
try {
currentSrc = existingFrame ? new URL(existingFrame.getAttribute('src') || existingFrame.src || '', window.location.href).href : ''
} catch (err) {}
let nextSrcAbs = nextSrc
try {
nextSrcAbs = new URL(nextSrc, window.location.href).href
} catch (err) {}
const iframe_elem = ensureMainFrame(currentSrc !== nextSrcAbs)
if (target.endsWith('.pdf')) {
iframe_elem.removeAttribute('sandbox')
} else {
@ -1408,7 +1455,9 @@
window.location.hash = getPreviewHashValueFromHref(rawTarget)
}
iframe_elem.src = isSnapshotRootPreview(target) ? snapshotFilesUrl : target
if (currentSrc !== nextSrcAbs) {
iframe_elem.src = nextSrc
}
return false
}

View File

@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.31rc42",
"version": "0.9.31rc43",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {

View File

@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.31rc42"
version = "0.9.31rc43"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@ -79,9 +79,9 @@ dependencies = [
### Extractor dependencies (optional binaries detected at runtime via shutil.which)
### Binary/Package Management
"abxbus>=2.5.4", # EventBus API
"abxpkg>=1.10.32", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.10.97", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.10.97", # shared ArchiveBox downloader package with blocking install preflight
"abxpkg>=1.10.34", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.10.99", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.10.99", # shared ArchiveBox downloader package with blocking install preflight
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
]