Compare commits

...
Author SHA1 Message Date
ArchiveBox Release Bot d96092d76d Bump release version to 0.9.35rc460 2026-09-17 23:27:21 +00:00
ArchiveBox Release Bot 3f54139240 Bump for abx-dl 1.12.281 2026-09-17 23:27:02 +00:00
ArchiveBox Release Bot 673a1fb4ed Bump release version to 0.9.35rc459 2026-09-17 23:16:00 +00:00
Nick Sweeting a711ae7da0 Fix trusted snapshot previews across server security modes 2026-09-17 16:15:17 -07:00
ArchiveBox Release Bot 680ef683ee Bump release version to 0.9.35rc458 2026-09-17 23:02:50 +00:00
ArchiveBox Release Bot 3350aba3f3 Bump for abx-dl 1.12.279 2026-09-17 23:02:24 +00:00
ArchiveBox Release Bot 83ed0e9a4c Bump release version to 0.9.35rc457 2026-09-17 22:36:53 +00:00
Nick Sweeting d3aa90b060 Persist TLSNotary opt-in before starting screenshot workers 2026-09-17 15:36:30 -07:00
ArchiveBox Release Bot e3a5f77981 Bump release version to 0.9.35rc456 2026-09-17 22:30:00 +00:00
Nick Sweeting 786a9d07df Report capture diagnostics when gallery verification output is missing 2026-09-17 15:29:46 -07:00
ArchiveBox Release Bot 510365a282 Bump release version to 0.9.35rc455 2026-09-17 22:21:13 +00:00
Nick Sweeting 4d12bfd650 Configure screenshot services before starting the server 2026-09-17 15:20:52 -07:00
Nick Sweeting 0f1a89b199 Preserve downloader layers and verify TLSNotary in the screenshot gallery 2026-09-17 15:20:51 -07:00
ArchiveBox Release Bot c73884e3d8 Bump release version to 0.9.35rc454 2026-09-17 22:20:36 +00:00
ArchiveBox Release Bot 06572f2e38 Bump for abx-dl 1.12.278 2026-09-17 22:20:14 +00:00
ArchiveBox Release Bot d7e9504a52 Bump release version to 0.9.35rc453 2026-09-17 21:40:09 +00:00
Nick Sweeting c09fa8ffd3 Cache collection totals outside progress requests 2026-09-17 14:39:55 -07:00
ArchiveBox Release Bot d00ffe9f3b Bump release version to 0.9.35rc452 2026-09-17 21:03:43 +00:00
Nick Sweeting 29d40addf4 Issue browser sessions from administrator API keys for native apps 2026-09-17 14:03:24 -07:00
ArchiveBox Release Bot b512553d75 Bump release version to 0.9.35rc451 2026-09-17 20:05:26 +00:00
ArchiveBox Release Bot a2f8d81b60 Bump for abx-dl 1.12.276 2026-09-17 20:05:09 +00:00
ArchiveBox Release Bot 63326f501a Bump release version to 0.9.35rc450 2026-09-17 19:41:39 +00:00
Nick Sweeting 79d864915d Bound crawl admission and fix server, search, and schedule contention 2026-09-17 12:40:48 -07:00
28 changed files with 718 additions and 134 deletions

No files matched your search

+20 -15
View File
@@ -10,10 +10,10 @@
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.274 \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.281 \
# -t archivebox:multistage
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.274
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.281
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
@@ -96,17 +96,15 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
<<'EOF'
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends
echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests
export UV_NO_CACHE=true
rm -f /etc/apt/apt.conf.d/docker-clean
apt-get update -qq
apt-get install -qq -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev x11-utils
apt-get install -qq -y --no-install-recommends x11-utils
# Extend abx-dl's existing venv instead of clearing it. Clearing and later
# copying a complete replacement over the base duplicates every venv byte in
# the final overlay history even when most packages are unchanged.
/usr/bin/uv pip install --no-cache setuptools pip wheel
# Do not rewrite inherited native libraries merely to strip them again.
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print > /tmp/archivebox-inherited-libraries
mkdir -p /tmp/archivebox-uv-project
/venv/bin/python - <<'PY'
@@ -155,25 +153,31 @@ PY
--inexact \
--no-install-project \
--no-install-workspace \
--no-install-package abxbus \
--no-install-package abxpkg \
--no-install-package abx-plugins \
--no-install-package abx-dl \
--no-sources
builder_abxpkg_lib_dir=/tmp/archivebox-builder-abxpkg
ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$builder_abxpkg_lib_dir" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print0 > /tmp/archivebox-native-libraries
while IFS= read -r -d '' native_library; do
if grep -Fxq "$native_library" /tmp/archivebox-inherited-libraries; then
continue
fi
magic=''
if IFS= read -r -N 4 magic < "$native_library" && [[ "$magic" == $'\x7fELF' ]]; then
"$builder_abxpkg_lib_dir/env/bin/strip" --strip-unneeded "$native_library" || exit $?
fi
done < /tmp/archivebox-native-libraries
rm -f /tmp/archivebox-native-libraries
rm -f /tmp/archivebox-native-libraries /tmp/archivebox-inherited-libraries
rm -f /venv/bin/uv /venv/bin/uvx
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils build-essential gcc libldap2-dev libsasl2-dev libssl-dev
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get autoremove -y
rm -rf "$builder_abxpkg_lib_dir"
rm -rf /venv/lib/python3.*/site-packages/pip* \
/venv/lib/python3.*/site-packages/wheel* \
/venv/bin/pip /venv/bin/pip3 /venv/bin/pip3.* /venv/bin/wheel
echo 'Binary::apt::APT::Keep-Downloaded-Packages "0";' > /etc/apt/apt.conf.d/99keep-cache
rm -rf /var/lib/apt/lists/* /tmp/archivebox-uv-project
EOF
@@ -202,7 +206,7 @@ RUN echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
&& test -f "$(/venv/bin/python -c 'import archivebox; print(archivebox.__cached__)')" \
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
# The builder installs and purges compilers in one layer, so its final
# The builder installs and purges stripping tools in one layer, so its final
# filesystem is already runtime-clean. Preserve that ancestry: starting again
# from archivebox-runtime-base and COPYing /venv would bake a second full venv
# over the inherited one instead of recording only ArchiveBox's package delta.
@@ -225,7 +229,8 @@ COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
# The builder invokes abxpkg as root for temporary ELF stripping, which can
# rewrite derived state ownership. Restore UID 911 before resolving binaries
# as the runtime user; the old copy-based final stage hid this dependency.
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777,id=archivebox-runtime-config-$TARGETARCH \
echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
@@ -239,8 +244,8 @@ RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..."
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
&& chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$ABXPKG_LIB_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
&& find "$ABXPKG_LIB_DIR" \( ! -user "$DEFAULT_ARCHIVEBOX_UID" -o ! -group "$DEFAULT_ARCHIVEBOX_GID" \) -exec chown -h "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" {} + \
&& UV_NO_CACHE=true setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
&& openssl rand -hex 16 > /etc/machine-id \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
+42 -1
View File
@@ -4,7 +4,7 @@ from django.http import HttpRequest
from ninja import Router, Schema
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token, HeaderTokenAuth, BearerTokenAuth
router = Router(tags=["Authentication"], auth=None)
@@ -63,3 +63,44 @@ def check_api_token(request: HttpRequest, token_data: TokenAuthSchema):
return {"success": True, "user_id": str(user.pk)}
return {"success": False, "user_id": None}
@router.post(
"/browser_session",
auth=[HeaderTokenAuth(), BearerTokenAuth()],
summary="Exchange an administrator API key for a normal browser session",
)
def browser_session(request: HttpRequest):
from django.conf import settings
from django.contrib.auth import login
from django.http import JsonResponse
from django.utils import timezone
from ninja.errors import HttpError
from archivebox.core.routes_util import get_admin_base_url
user = request.auth
if not user.is_active or not user.is_superuser:
raise HttpError(403, "An active administrator is required")
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
# A browser session must not outlive the key used to establish it.
token = request._api_token
lifetime = settings.SESSION_COOKIE_AGE
if token.expires:
lifetime = min(lifetime, max(0, int((token.expires - timezone.now()).total_seconds())))
request.session.set_expiry(lifetime)
request.session.save()
response = JsonResponse(
{
"admin_url": get_admin_base_url(request=request).rstrip("/") + "/admin/",
"cookie": {
"name": settings.SESSION_COOKIE_NAME,
"value": request.session.session_key,
"expires": request.session.get_expiry_date().timestamp(),
"secure": settings.SESSION_COOKIE_SECURE,
},
},
)
# The native client installs a host-only HttpOnly cookie on the admin origin;
# API and admin hosts can differ, so a Set-Cookie on the API host is insufficient.
response["Cache-Control"] = "no-store"
return response
-4
View File
@@ -388,10 +388,6 @@ def main(**kwargs):
raise click.BadParameter(str(err), param_hint="--snapshot-max-size") from err
if kwargs.get("crawl_max_concurrent_snapshots") is not None and int(kwargs["crawl_max_concurrent_snapshots"]) < 1:
raise click.BadParameter("crawl_max_concurrent_snapshots must be at least 1.", param_hint="--crawl-max-concurrent-snapshots")
if not kwargs.get("index_only"):
from archivebox.workers.supervisord_util import require_crawl_memory
require_crawl_memory()
# Translate --only-new/--no-only-new into a crawl config override.
# add() takes config overrides as a dict; no per-flag kwargs.
+11 -8
View File
@@ -363,12 +363,10 @@ def server(
command = current_command(Process.TypeChoices.SERVER, data_dir=CONSTANTS.DATA_DIR, url=bind_url)
def still_owns_runtime_stack() -> bool:
from django.db import connections
try:
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
finally:
connections.close_all()
# This is polled at the log-tail cadence. Keep the autocommit
# connection open so SQLite can reuse its page cache between polls;
# reconnecting here repeatedly rereads the process table while idle.
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
shutdown_state = None
try:
@@ -416,8 +414,13 @@ def server(
except KeyboardInterrupt:
pass
finally:
if not shutdown_state or not shutdown_state.signal_name:
command.mark_exited()
from django.db import connections
try:
if not shutdown_state or not shutdown_state.signal_name:
command.mark_exited()
finally:
connections.close_all()
print("\n[i][green][🟩] ArchiveBox server shut down gracefully.[/green][/i]")
+6 -1
View File
@@ -765,6 +765,11 @@ def _plugin_full_preview_response(
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-ArchiveBox-Security-Mode"] = request.archivebox_config.SERVER_SECURITY_MODE
response.headers["Referrer-Policy"] = "no-referrer"
# Trusted viewers live on the snapshot origin, but the collection UI lives
# on web.* (and can be embedded by admin.*). Permit only those configured
# origins, not arbitrary sites or other snapshots. This does not grant the
# archived document access to the parent frame or its admin session cookies.
viewer_origins = " ".join(dict.fromkeys((build_web_url(request=request), build_admin_url(request=request))))
response.headers["Content-Security-Policy"] = (
"default-src 'self' data: blob:; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
@@ -778,7 +783,7 @@ def _plugin_full_preview_response(
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
"frame-ancestors 'self';"
f"frame-ancestors 'self' {viewer_origins};"
)
return response
+16 -2
View File
@@ -1230,7 +1230,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
(
"Configuration",
{
"fields": ("schedule", "template"),
"fields": ("is_enabled", "schedule", "template"),
"classes": ("card",),
},
),
@@ -1276,7 +1276,21 @@ class CrawlScheduleAdmin(BaseModelAdmin):
def change_view(self, request, object_id, form_url="", extra_context=None):
self.request = request
return super().change_view(request, object_id, form_url, extra_context)
from django.db import connections, router
connection = connections[router.db_for_write(self.model)]
if request.method != "POST" or connection.vendor != "sqlite":
return super().change_view(request, object_id, form_url, extra_context)
# Reserve the writer before Django's atomic admin form reads. A
# deferred read transaction cannot safely upgrade while the runner
# writes, even when SQLite's existing busy timeout has time left.
connection.ensure_connection()
previous_mode = connection.transaction_mode
connection.transaction_mode = "IMMEDIATE"
try:
return super().change_view(request, object_id, form_url, extra_context)
finally:
connection.transaction_mode = previous_mode
def add_view(self, request, form_url="", extra_context=None):
return redirect("/add/#schedule")
+54
View File
@@ -0,0 +1,54 @@
"""Non-blocking, database-only collection totals for the progress monitor."""
import logging
from threading import Lock, Thread
from time import time
from django.core.cache import cache
from django.db import close_old_connections, connections
from django.db.models import Count, Sum
_refresh_lock = Lock()
def collection_summary(user):
user_id = None if user.is_superuser else user.pk
key = f"progress-collection:{'all' if user_id is None else user_id}"
summary = cache.get(key)
# Even an indexed SUM scans the index. Never make an HTTP request wait for
# that scan on a large collection. Serve stale totals while one worker refreshes;
# a single non-blocking lock prevents a queue of scans from concurrent clients.
if (summary is None or time() - summary["sampled_at"] >= 60) and _refresh_lock.acquire(blocking=False):
# Throttle failures too: a broken/locked database must not trigger work
# and log output on every sidebar poll. This cache is local to each worker.
if not cache.add(f"{key}:refresh", True, timeout=60):
_refresh_lock.release()
return summary
def refresh():
try:
close_old_connections()
from archivebox.core.models import Snapshot
snapshots = Snapshot.objects.all()
if user_id is not None:
snapshots = snapshots.filter(crawl__created_by_id=user_id)
# COUNT(*) + SUM(output_size) can use Snapshot's covering size index.
# Snapshot.output_size is maintained when ArchiveResult sizes change.
totals = snapshots.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
totals["bytes"] = totals["bytes"] or 0
totals["sampled_at"] = time()
cache.set(key, totals, timeout=3600)
except Exception:
logging.getLogger(__name__).exception("Could not refresh collection totals")
finally:
connections.close_all()
_refresh_lock.release()
try:
Thread(target=refresh, name="collection-summary", daemon=True).start()
except RuntimeError:
_refresh_lock.release()
logging.getLogger(__name__).exception("Could not start collection totals refresh")
return summary
+4
View File
@@ -942,6 +942,10 @@ def live_progress_view(request):
"queued_crawls_hidden": queued_crawls_hidden,
"server_time": timezone.now().isoformat(),
}
if is_admin and not snapshot_id_filter and not crawl_id_filter and request.GET.get("collection") == "1":
from archivebox.progressmonitor.collection import collection_summary
payload["collection"] = collection_summary(request.user)
try:
import ujson
+14 -7
View File
@@ -221,6 +221,7 @@ def iter_query_search_ids(
search_mode: str | None = None,
config: dict[str, Any] | None = None,
max_results: int | None = None,
stop_event=None,
**config_kwargs: Any,
):
"""Yield snapshot IDs from configured search backend modules."""
@@ -267,6 +268,8 @@ def iter_query_search_ids(
seen: set[str] = set()
try:
for backend_name in backend_names:
if stop_event is not None and stop_event.is_set():
return
backend = backends[backend_name]
try:
if backend_name == "sonic":
@@ -292,14 +295,18 @@ def iter_query_search_ids(
env=search_backend_command_env(config=config),
cwd=CONSTANTS.DATA_DIR,
timeout=max(1, int(config.get("TIMEOUT", 60))) * 4,
stop_event=stop_event,
)
for snapshot_id in ids:
if snapshot_id in seen:
continue
seen.add(snapshot_id)
yield snapshot_id
if max_results and len(seen) >= max_results:
return
try:
for snapshot_id in ids:
if snapshot_id in seen:
continue
seen.add(snapshot_id)
yield snapshot_id
if max_results and len(seen) >= max_results:
return
finally:
ids.close()
successful_backends += 1
except Exception as err:
errors.append(err)
+13 -7
View File
@@ -6,7 +6,7 @@ import json
import threading
import time
from copy import copy
from queue import Full, Queue
from queue import Empty, Full, Queue
from urllib.parse import urlsplit
from uuid import UUID
@@ -254,15 +254,18 @@ def iter_filtered_search_result_ids(iterator, queryset, *, flush_max_delay=0.05)
yield from flush_batch()
def iter_search_result_ids(query, base_queryset, *, search_mode, config):
def iter_search_result_ids(query, base_queryset, *, search_mode, config, stop_event=None):
"""Yield filtered Snapshot IDs from the selected search provider."""
search_mode_base = get_search_mode_base(search_mode, config=config)
provider = (
iter_meta_search_ids(query, base_queryset)
if search_mode_base == "meta"
else iter_query_search_ids(query, search_mode=search_mode, config=config)
else iter_query_search_ids(query, search_mode=search_mode, config=config, stop_event=stop_event)
)
yield from iter_filtered_search_result_ids(provider, base_queryset)
try:
yield from iter_filtered_search_result_ids(provider, base_queryset)
finally:
provider.close()
def snapshot_search_stream_response(query, base_queryset, *, search_mode, config, cache_key, thread_name):
@@ -300,7 +303,7 @@ def snapshot_search_stream_response(query, base_queryset, *, search_mode, config
iterator = None
try:
close_old_connections()
iterator = iter_search_result_ids(query, base_queryset, search_mode=search_mode, config=config)
iterator = iter_search_result_ids(query, base_queryset, search_mode=search_mode, config=config, stop_event=stop_event)
for snapshot_id in iterator:
if stop_event.is_set():
break
@@ -326,10 +329,13 @@ def snapshot_search_stream_response(query, base_queryset, *, search_mode, config
emit(None)
threading.Thread(target=run_search, name=thread_name, daemon=True).start()
yield f"0{stream_padding}\n"
try:
yield f"0{stream_padding}\n"
while True:
item = await asyncio.to_thread(queue.get)
try:
item = await asyncio.to_thread(queue.get, True, 0.1)
except Empty:
continue
if item is None:
break
if isinstance(item, BaseException):
+91
View File
@@ -0,0 +1,91 @@
"""Admission signals, not estimates of how much RAM an extractor will need."""
import math
import os
import time
from pathlib import Path
RESOURCE_RECHECK_SECONDS = 2.0
# Scheduling policy, not a minimum-RAM claim: stop increasing extraction
# load when all non-idle work loses at least one second out of ten to memory
# stalls. Require both recent and rolling pressure so transient reclaim does
# not indefinitely starve otherwise viable work on small hosts.
MEMORY_FULL_STALL_PERCENT = 10.0
class ResourceAdmission:
def __init__(self):
self._memory_stalls: dict[str, tuple[int, float]] = {}
self._checked_at = float("-inf")
self._reason: str | None = None
@staticmethod
def cgroup_paths() -> list[Path]:
root = Path("/sys/fs/cgroup")
paths = [root]
try:
for line in Path("/proc/self/cgroup").read_text().splitlines():
if line.startswith("0::"):
current = root / line[3:].lstrip("/")
if current.is_dir() and current.is_relative_to(root):
paths = [current, *[parent for parent in current.parents if parent.is_relative_to(root)]]
break
except OSError:
pass
return paths
def snapshot_limit(self, configured: int) -> int:
cpus = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else (os.cpu_count() or 1)
for path in self.cgroup_paths():
try:
quota, period = (path / "cpu.max").read_text().split()
if quota != "max":
cpus = min(cpus, max(1, math.ceil(int(quota) / int(period))))
except (OSError, ValueError, ZeroDivisionError):
continue
# CPU quota limits parallel admission, not forward progress. A host
# with a fractional CPU must still be able to run one snapshot.
return max(1, min(configured, cpus))
def observe_memory_stalls(self, source: str, total: int, avg10: float, sampled_at: float) -> bool:
previous = self._memory_stalls.get(source)
self._memory_stalls[source] = (total, sampled_at)
if previous is None:
return avg10 >= MEMORY_FULL_STALL_PERCENT
previous_total, previous_time = previous
elapsed = sampled_at - previous_time
if elapsed <= 0 or total < previous_total:
return False
stalled_percent = (total - previous_total) / (elapsed * 1_000_000) * 100
return avg10 >= MEMORY_FULL_STALL_PERCENT and stalled_percent >= MEMORY_FULL_STALL_PERCENT
def memory_pressure(self) -> str | None:
now = time.monotonic()
if now - self._checked_at < RESOURCE_RECHECK_SECONDS:
return self._reason
self._checked_at = now
reasons = []
cgroups = self.cgroup_paths()
for path in cgroups:
try:
current = int((path / "memory.current").read_text())
for name in ("memory.high", "memory.max"):
limit = (path / name).read_text().strip()
if limit != "max" and current >= int(limit):
reasons.append(f"{name} reached")
except (OSError, ValueError):
continue
for path in [Path("/proc/pressure/memory"), *[p / "memory.pressure" for p in cgroups]]:
try:
full = next(line for line in path.read_text().splitlines() if line.startswith("full "))
fields = dict(field.split("=", 1) for field in full.split()[1:])
if self.observe_memory_stalls(str(path), int(fields["total"]), float(fields["avg10"]), now):
reasons.append("memory stalls")
except (OSError, ValueError, KeyError, StopIteration):
continue
self._reason = ", ".join(dict.fromkeys(reasons)) or None
return self._reason
resource_admission = ResourceAdmission()
+51 -4
View File
@@ -58,6 +58,8 @@ from abxbus import BaseEvent
from abxbus.event_bus import EventBus, get_current_event, in_handler_context
from abxbus.event_handler import EventHandlerAbortedError, EventHandlerCancelledError
from archivebox.services.resource_admission import RESOURCE_RECHECK_SECONDS, resource_admission
from archivebox.config.common import (
ArchiveBoxBaseConfig,
normalize_runtime_config,
@@ -221,6 +223,8 @@ class CrawlRunner:
self.snapshot_tasks: dict[str, asyncio.Task[None]] = {}
self.snapshot_semaphore = asyncio.Semaphore(1)
self.max_concurrent_snapshots = 1
self._resource_deferred = False
self._initial_snapshot_admitted = False
self.persona = None
self.base_config: ArchiveBoxBaseConfig | dict[str, Any] = {}
self.derived_config: dict[str, Any] = {}
@@ -294,6 +298,7 @@ class CrawlRunner:
async def run(self) -> None:
root_snapshot_id: str | None = None
bus_destroyed = False
run_state_loaded = False
try:
first_signal_message = (
"\n[🛑] Got {signal_name}, aborting the active hook...\n"
@@ -313,8 +318,15 @@ class CrawlRunner:
on_signal=self._request_abort_from_signal,
raise_on_first_signal=False,
):
if await sync_to_async(defer_crawl_for_resources, thread_sensitive=True)(self.crawl):
return
# Startup and its first snapshot are one admission. Browser
# startup can itself cause reclaim; checking again immediately
# would tear it down and repeat startup without doing any work.
self._initial_snapshot_admitted = True
run_state_loaded = True
snapshot_ids = await sync_to_async(self.load_run_state, thread_sensitive=True)()
max_concurrent_snapshots = max(1, int(self.base_config.get("CRAWL_MAX_CONCURRENT_SNAPSHOTS", 1)))
max_concurrent_snapshots = resource_admission.snapshot_limit(int(self.base_config.get("CRAWL_MAX_CONCURRENT_SNAPSHOTS", 1)))
self.max_concurrent_snapshots = max_concurrent_snapshots
self.snapshot_semaphore = asyncio.Semaphore(max_concurrent_snapshots)
live_ui = self._create_live_ui()
@@ -344,10 +356,19 @@ class CrawlRunner:
except Exception:
pass
self._live_stream = None
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
if run_state_loaded:
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
async def enqueue_snapshot(self, snapshot_id: str, crawl_start_event: CrawlStartEvent | None = None) -> None:
if self._resource_deferred:
return
if not self._initial_snapshot_admitted and await sync_to_async(defer_crawl_for_resources, thread_sensitive=True)(
self.crawl,
excluded_snapshot_ids=list(self.snapshot_tasks),
):
self._resource_deferred = True
return
if await self.crawl_is_cancelled():
return
if await self.crawl_is_paused() and not self.allow_maintenance_on_inactive_crawl:
@@ -362,6 +383,7 @@ class CrawlRunner:
return
else:
task = asyncio.create_task(self.run_snapshot(snapshot_id), context=_runner_task_context())
self._initial_snapshot_admitted = False
self.snapshot_tasks[snapshot_id] = task
async def stop_snapshot_tasks(self) -> None:
@@ -488,6 +510,8 @@ class CrawlRunner:
from archivebox.core.models import Snapshot
from archivebox.config.common import get_config
if self._resource_deferred:
return
if not isinstance(get_current_event(), CrawlStartEvent):
return
if await self.crawl_is_cancelled():
@@ -497,7 +521,7 @@ class CrawlRunner:
await sync_to_async(self.crawl.refresh_from_db, thread_sensitive=True)()
config = await sync_to_async(lambda: get_config(crawl=self.crawl), thread_sensitive=True)()
self.max_concurrent_snapshots = max(1, int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
self.max_concurrent_snapshots = resource_admission.snapshot_limit(int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
active_snapshot_ids = [snapshot_id for snapshot_id, task in self.snapshot_tasks.items() if not task.done()]
available_slots = max(0, self.max_concurrent_snapshots - len(active_snapshot_ids))
@@ -1266,6 +1290,29 @@ def run_snapshot_maintenance(snapshot_id: str, *, output_dir: Path | None = None
return True
def defer_crawl_for_resources(crawl, *, excluded_snapshot_ids=()) -> bool:
"""Leave extraction durable; never delay cancellation or final cleanup."""
from archivebox.core.models import Snapshot
if crawl.status not in (crawl.StatusChoices.QUEUED, crawl.StatusChoices.STARTED):
return False
reason = resource_admission.memory_pressure()
if reason is None:
return False
pending = crawl.snapshot_set.filter(status__in=Snapshot.RUNNABLE_STATES).exclude(id__in=excluded_snapshot_ids)
if crawl.snapshot_set.exists() and not pending.exists():
return False
now = timezone.now()
retry_at = now + timedelta(seconds=RESOURCE_RECHECK_SECONDS)
# Conditional short updates cannot resurrect a concurrent pause/cancel.
type(crawl).objects.filter(id=crawl.id, status__in=[crawl.StatusChoices.QUEUED, crawl.StatusChoices.STARTED]).update(
retry_at=retry_at,
modified_at=now,
)
pending.filter(retry_at__lte=now).update(retry_at=retry_at, modified_at=now)
return True
def run_due_crawl(crawl, *, lock_seconds: int, interactive_interrupts: bool = False) -> bool:
with crawl_lifecycle_lock(str(crawl.id)):
return _run_due_crawl_locked(
+6 -4
View File
@@ -1890,14 +1890,13 @@
function attachPreviewFrameHandlers(frame) {
resetPreviewFrameSize(frame)
if (frame.src.endsWith('.pdf')) {
if (new URL(frame.src || 'about:blank', window.location.href).pathname.endsWith('.pdf')) {
frame.removeAttribute('sandbox')
frame.src = frame.src + '#toolbar=0'
}
frame.onload = function() {
if (this.src.includes('.pdf')) {
if (new URL(this.src || 'about:blank', window.location.href).pathname.endsWith('.pdf')) {
this.removeAttribute('sandbox')
this.src = this.src.split('?autoplay=')[0] + '#toolbar=0'
}
tryCenterImageFrame(this)
try {
@@ -2083,7 +2082,10 @@
nextSrcAbs = new URL(nextSrc, window.location.href).href
} catch (err) {}
const iframe_elem = ensureMainFrame(currentSrc !== nextSrcAbs)
if (target.endsWith('.pdf')) {
// Preview wrappers retain the .pdf path but add ?preview=1.
// The native PDF renderer must be allowed before navigation,
// not after a failed sandboxed load has already completed.
if (new URL(target, window.location.href).pathname.endsWith('.pdf')) {
iframe_elem.removeAttribute('sandbox')
} else {
iframe_elem.sandbox = "allow-same-origin allow-top-navigation-by-user-activation allow-scripts allow-forms"
@@ -34,7 +34,7 @@
{% elif mode == "high_load" %}
15-min loadavg <code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ load_15 }}</code>
exceeds <code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ load_threshold }}</code>
({{ cpu_count }} cores × 3) — the host is saturated; crawls are queuing faster than the runner can finish them.
({{ cpu_count }} cores × 3) — recent CPU or I/O contention may slow crawls. This average can stay elevated after load subsides; it does not measure queue growth.
{% elif mode == "unsafe" %}
ArchiveBox single-domain mode — archived pages share an origin with this site
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Modes" target="_blank" rel="noopener noreferrer" style="color:#fff;text-decoration:underline;margin-left:6px;">why?</a>
@@ -0,0 +1,56 @@
from datetime import timedelta
from urllib.parse import urlsplit
import pytest
from django.contrib.sessions.models import Session
from django.utils import timezone
from archivebox.api.models import APIToken
from archivebox.tests.conftest import API_TEST_HOST, api_client_request
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("header", ["HTTP_AUTHORIZATION", "HTTP_X_ARCHIVEBOX_API_KEY"])
def test_browser_session_authenticates_admin_and_expires_with_key(client, api_admin_user, header):
token = APIToken.objects.create(created_by=api_admin_user, expires=timezone.now() + timedelta(minutes=5))
response = api_client_request(
client,
"post",
"/api/v1/auth/browser_session",
payload={},
headers={
"HTTP_HOST": API_TEST_HOST,
header: f"Bearer {token.token}" if header == "HTTP_AUTHORIZATION" else token.token,
},
)
assert response.status_code == 200, response.content
assert response["Cache-Control"] == "no-store"
data = response.json()
session = Session.objects.get(session_key=data["cookie"]["value"])
assert session.get_decoded()["_auth_user_id"] == str(api_admin_user.pk)
assert timezone.now() < session.expire_date <= token.expires
client.cookies.clear()
client.cookies[data["cookie"]["name"]] = data["cookie"]["value"]
response = client.get("/admin/", HTTP_HOST=urlsplit(data["admin_url"]).netloc)
assert response.status_code == 200, response.content
assert response.wsgi_request.user.pk == api_admin_user.pk
@pytest.mark.parametrize("kind", ["missing", "invalid", "expired", "inactive", "nonadmin", "query"])
def test_browser_session_rejects_invalid_credentials(client, api_admin_user, kind):
token = APIToken.objects.create(created_by=api_admin_user, expires=timezone.now() + timedelta(minutes=5))
if kind == "expired":
token.expires = timezone.now() - timedelta(seconds=1)
token.save()
if kind in ("inactive", "nonadmin"):
setattr(api_admin_user, "is_active" if kind == "inactive" else "is_superuser", False)
api_admin_user.save()
headers = {"HTTP_HOST": API_TEST_HOST}
if kind not in ("missing", "query"):
headers["HTTP_AUTHORIZATION"] = "Bearer " + ("invalid" if kind == "invalid" else token.token)
path = "/api/v1/auth/browser_session" + (f"?api_key={token.token}" if kind == "query" else "")
before = Session.objects.count()
response = api_client_request(client, "post", path, payload={}, headers=headers)
assert response.status_code in (401, 403), response.content
assert Session.objects.count() == before
+16 -20
View File
@@ -230,27 +230,23 @@ def test_add_single_url_records_url_in_crawl(initialized_archive):
assert snapshots == []
def test_add_memory_preflight_fails_before_creating_crawl(initialized_archive, capsys):
from archivebox.workers.supervisord_util import MIN_CRAWL_AVAILABLE_MEMORY_BYTES, require_crawl_memory
assert MIN_CRAWL_AVAILABLE_MEMORY_BYTES == 512 * 1024 * 1024
require_crawl_memory(MIN_CRAWL_AVAILABLE_MEMORY_BYTES)
def test_add_bg_persists_queue_without_starting_extraction_workers(initialized_archive):
result = run_archivebox_cmd(
["add", "--bg", "--plugins=wget", "--crawl-max-concurrent-snapshots=4", "https://example.com", "https://example.org"],
cwd=initialized_archive,
env=cli_env(),
)
assert result.returncode == 0, result.stderr
with use_archivebox_db(initialized_archive):
before_crawls = Crawl.objects.count()
before_processes = Process.objects.count()
with pytest.raises(SystemExit) as err:
require_crawl_memory(MIN_CRAWL_AVAILABLE_MEMORY_BYTES - 1)
assert Crawl.objects.count() == before_crawls
assert Process.objects.count() == before_processes
assert err.value.code == 1
output = capsys.readouterr().err
assert "Not enough available memory to archive a crawl" in output
assert "No crawl, runner, or Sonic workers were started" in output
assert "configure swap" in output
crawl = Crawl.objects.get()
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is not None
assert crawl.get_urls_list() == ["https://example.com", "https://example.org"]
assert crawl.config["PLUGINS"] == "wget"
assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 4
assert not Snapshot.objects.exists()
assert not ArchiveResult.objects.exists()
assert not Process.objects.filter(process_type__in=[Process.TypeChoices.ORCHESTRATOR, Process.TypeChoices.HOOK]).exists()
def test_initial_crawl_creation_does_not_warn_as_an_outside_runner_update(initialized_archive, caplog):
@@ -0,0 +1,30 @@
import os
from archivebox.services.resource_admission import ResourceAdmission
def test_cpu_admission_respects_process_affinity():
admission = ResourceAdmission()
available = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
assert 1 <= admission.snapshot_limit(100000) <= available
assert admission.snapshot_limit(1) == 1
def test_memory_stall_counter_recovers_without_resetting_monitor():
# Real kernel PSI units: cumulative microseconds and ten-second average.
# Decision samples exercise the policy, not simulated hooks/processes.
admission = ResourceAdmission()
assert admission.observe_memory_stalls("host", 100, 0.0, 0.0) is False
assert admission.observe_memory_stalls("host", 400100, 20.0, 2.0) is True
assert admission.observe_memory_stalls("host", 400100, 20.0, 4.0) is False
assert admission.observe_memory_stalls("new-cgroup", 900, 20.0, 0.0) is True
assert admission.observe_memory_stalls("new-cgroup", 900, 20.0, 2.0) is False
def test_transient_and_low_memory_stalls_do_not_starve_admission():
admission = ResourceAdmission()
for sample in range(20):
assert admission.observe_memory_stalls("host", sample * 2600, 0.13, sample * 2.0) is False
# One large interval alone is not sustained pressure; the rolling average
# must agree before deferring work.
assert admission.observe_memory_stalls("host", 1_000_000, 0.13, 40.0) is False
+72
View File
@@ -1289,3 +1289,75 @@ class TestSearchBackendsE2E:
fixture_server.shutdown()
fixture_server.server_close()
fixture_thread.join(timeout=5)
@pytest.mark.timeout(90)
def test_live_public_search_disconnect_reaps_backend(initialized_archive):
"""A real HTTP disconnect cancels a silent ripgrep process and its wrapper."""
import signal
from uuid import uuid4
import psutil
from archivebox.core.models import Snapshot
from archivebox.tests.test_orm_helpers import use_archivebox_db
rg = shutil.which("rg")
assert rg
query = "disconnect" + uuid4().hex
port = get_free_port()
env = cli_env(
live=True,
PUBLIC_INDEX="True",
PERMISSIONS="public",
SEARCH_BACKEND_ENGINE="ripgrep",
SEARCH_BACKEND_RIPGREP_ENABLED="True",
RIPGREP_BINARY=rg,
RIPGREP_ARGS_EXTRA='["--no-mmap", "--text", "--sort", "path"]',
RIPGREP_TIMEOUT="30",
ALLOWED_HOSTS="*",
)
created = run_archivebox_cmd(
["snapshot", "create", "--status=sealed", "https://example.com/disconnect-test"],
cwd=initialized_archive,
env=env,
)
assert created.returncode == 0, created.stderr
with use_archivebox_db(initialized_archive):
snapshot = Snapshot.objects.get(url="https://example.com/disconnect-test")
directory = snapshot.output_dir / "dom"
directory.mkdir(parents=True, exist_ok=True)
(directory / "a.txt").write_text(query + "\n")
with (directory / "z.txt").open("wb") as large:
large.truncate(8 * 1024**3)
for offset in range(0, 8 * 1024**3, 1024**2):
large.seek(offset)
large.write(b"\n")
server = start_archivebox_server(initialized_archive, port=port, env=env, daemonize=False, log_name="disconnect-server.log")
children = []
try:
with requests.get(
f"http://127.0.0.1:{port}/public/search-stream/",
params={"q": query, "search_mode": "deep:ripgrep"},
headers={"Host": f"web.archivebox.localhost:{port}"},
stream=True,
timeout=(5, 10),
) as response:
assert response.status_code == 200, response.text
lines = response.iter_lines()
assert int(next(lines).strip()) == 0
assert int(next(lines).strip()) == 1
children = [child for child in psutil.Process(server.pid).children(recursive=True) if query in " ".join(child.cmdline())]
assert len(children) == 2
for child in children:
if Path(child.exe()).name == Path(rg).name:
child.send_signal(signal.SIGSTOP)
_, alive = psutil.wait_procs(children, timeout=5)
assert not alive, [(child.pid, child.status()) for child in alive]
finally:
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
stop_archivebox_process(server)
+22 -12
View File
@@ -14,6 +14,7 @@ pytestmark = pytest.mark.django_db
class TestCrawlScheduleAdmin:
@pytest.mark.django_db(transaction=True)
def test_crawlschedule_change_view_renders_and_saves(self, client, admin_user, crawl):
from archivebox.crawls.models import CrawlSchedule
@@ -35,24 +36,33 @@ class TestCrawlScheduleAdmin:
assert b"No Crawls yet..." not in get_response.content
assert b"No Snapshots yet..." not in get_response.content
post_response = client.post(
change_url,
{
"label": "Morning crawl",
"notes": "updated",
"schedule": "0 8 * * *",
"template": str(crawl.pk),
"created_by": str(admin_user.pk),
"_save": "Save",
},
HTTP_HOST=ADMIN_TEST_HOST,
)
from django.db import connection
from django.test.utils import CaptureQueriesContext
previous_mode = getattr(connection, "transaction_mode", None)
with CaptureQueriesContext(connection) as queries:
post_response = client.post(
change_url,
{
"label": "Morning crawl",
"notes": "updated",
"schedule": "0 8 * * *",
"template": str(crawl.pk),
"created_by": str(admin_user.pk),
"_save": "Save",
},
HTTP_HOST=ADMIN_TEST_HOST,
)
if connection.vendor == "sqlite":
assert any(query["sql"] == "BEGIN IMMEDIATE" for query in queries)
assert connection.transaction_mode == previous_mode
assert post_response.status_code == 302
schedule.refresh_from_db()
assert schedule.label == "Morning crawl"
assert schedule.notes == "updated"
assert schedule.schedule == "0 8 * * *"
assert schedule.is_enabled is False
assert schedule.template_id == crawl.pk
assert schedule.created_by_id == admin_user.pk
+68
View File
@@ -104,6 +104,74 @@ def real_crawl_setup_process(snapshot, hermetic_lib_dir):
class TestLiveProgressView:
def test_collection_summary_reports_stored_sizes(self, client, admin_user, snapshot):
import time
from django.core.cache import cache
from django.db.models import Count, Sum
from archivebox.core.models import Snapshot
Snapshot.objects.filter(pk=snapshot.pk).update(output_size=12345)
expected = Snapshot.objects.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
cache.delete_many(["progress-collection:all", "progress-collection:all:refresh"])
client.force_login(admin_user)
deadline = time.monotonic() + 5
while True:
response = client.get(reverse("live_progress"), {"collection": "1"}, HTTP_HOST=ADMIN_TEST_HOST)
assert response.status_code == 200
summary = response.json().get("collection")
if summary is not None or time.monotonic() >= deadline:
break
time.sleep(0.05)
assert summary is not None
assert summary["snapshots"] == expected["snapshots"]
assert summary["bytes"] == expected["bytes"]
from django.db import connection
from django.test.utils import CaptureQueriesContext
if connection.vendor == "sqlite":
with connection.cursor() as cursor:
cursor.execute("EXPLAIN QUERY PLAN SELECT COUNT(*), SUM(output_size) FROM core_snapshot")
plan = " ".join(str(row) for row in cursor.fetchall())
assert "COVERING INDEX" in plan, plan
timings = []
with CaptureQueriesContext(connection) as queries:
for _ in range(20):
start = time.perf_counter()
response = client.get(reverse("live_progress"), {"collection": "1"}, HTTP_HOST=ADMIN_TEST_HOST)
timings.append((time.perf_counter() - start) * 1000)
assert response.status_code == 200
assert response.json()["collection"] == summary
assert not any('AS "bytes"' in query["sql"] for query in queries)
print(f"cached progress response: median={sorted(timings)[10]:.1f}ms max={max(timings):.1f}ms")
def test_collection_summary_is_scoped_to_staff_owner(self, client, admin_user, snapshot):
import time
from django.core.cache import cache
from archivebox.core.models import Snapshot
admin_user.is_superuser = False
admin_user.save(update_fields=["is_superuser"])
from django.contrib.auth import get_user_model
# Give the snapshot another owner; staff must not learn its size/count.
snapshot.crawl.created_by = get_user_model().objects.create_user(username="other-summary-owner")
snapshot.crawl.save(update_fields=["created_by"])
Snapshot.objects.filter(pk=snapshot.pk).update(output_size=12345)
cache.delete_many([f"progress-collection:{admin_user.pk}", f"progress-collection:{admin_user.pk}:refresh"])
client.force_login(admin_user)
deadline = time.monotonic() + 5
while True:
response = client.get(reverse("live_progress"), {"collection": "1"}, HTTP_HOST=ADMIN_TEST_HOST)
assert response.status_code == 200
summary = response.json().get("collection")
if summary is not None or time.monotonic() >= deadline:
break
time.sleep(0.05)
assert summary is not None
assert summary["snapshots"] == 0
assert summary["bytes"] == 0
def test_live_progress_rejects_unauthenticated_unscoped_request(self, client):
response = client.get(reverse("live_progress"), HTTP_HOST=ADMIN_TEST_HOST)
+61 -1
View File
@@ -1431,7 +1431,7 @@ class TestUrlRouting:
[
"add",
"--depth=0",
"--plugins=consolelog,screenshot,chrome_mhtml",
"--plugins=consolelog,screenshot,pdf,chrome_mhtml",
console_source_url,
],
cwd=self.data_dir,
@@ -1475,6 +1475,14 @@ class TestUrlRouting:
resp = client.get(f"/{screenshot_rel}?preview=1", HTTP_HOST=snapshot_host)
assert resp.status_code == 200
assert resp["Content-Type"].startswith("text/html")
assert f"frame-ancestors 'self' http://{web_host}" in resp["Content-Security-Policy"]
resp = client.get("/pdf/output.pdf?preview=1", HTTP_HOST=snapshot_host)
assert resp.status_code == 200
pdf_preview = response_body(resp).decode("utf-8")
assert '<iframe ' in pdf_preview
assert '<embed ' not in pdf_preview
assert "object-src 'none'" in resp["Content-Security-Policy"]
root_screenshot = screenshot_file.read_bytes()
import shutil
@@ -1507,6 +1515,58 @@ class TestUrlRouting:
""".replace("__CONSOLE_MARKER__", console_marker),
)
# Exercise the same real captured files with each request-time posture,
# including both auto branches and an HTTPS canonical reverse-proxy URL.
for mode, base in (
("auto", "http://archivebox.localhost:8000"),
("auto", "http://archivebox.example:8000"),
("safe-subdomains-fullreplay", "https://archivebox.example"),
("safe-onedomain-nojsreplay", "http://archivebox.example:8000"),
("unsafe-onedomain-noadmin", "http://archivebox.example:8000"),
("danger-onedomain-fullreplay", "http://archivebox.example:8000"),
):
self._run(
"""
from urllib.parse import urlsplit
from archivebox.config.common import get_request_config
snapshot = get_snapshot()
client = Client()
for path in ('screenshot/screenshot.png', 'pdf/output.pdf', 'chrome_mhtml/snapshot.mhtml'):
target = urlsplit(build_snapshot_url(str(snapshot.id), path))
response = client.get(target.path + '?preview=1', HTTP_HOST=target.netloc)
assert response.status_code == 200, (target, response.status_code)
csp = response['Content-Security-Policy']
config = get_request_config(response.wsgi_request)
origins = tuple(dict.fromkeys((get_web_base_url(config=config), get_admin_base_url(config=config))))
ancestors = next(d.strip() for d in csp.split(';') if d.strip().startswith('frame-ancestors'))
assert ancestors == "frame-ancestors 'self' " + ' '.join(origins), ancestors
assert '*' not in ancestors # no unrelated snapshots/sites
assert "object-src 'none'" in csp
assert "form-action 'none'" in csp
assert "base-uri 'none'" in csp
assert "script-src 'self'" in csp # trusted viewer can run
if path.endswith('.mhtml'):
assert 'sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"' in response_body(response).decode()
dangerous, _, _ = write_replay_fixtures(snapshot)
target = urlsplit(build_snapshot_url(str(snapshot.id), dangerous))
response = client.get(target.path, HTTP_HOST=target.netloc)
assert response.status_code == 200
config = get_request_config(response.wsgi_request)
csp = response.headers.get('Content-Security-Policy', '')
assert ("script-src 'none'" in csp) == config.SHOULD_NEUTER_RISKY_REPLAY
assert ('sandbox;' in csp) == config.SHOULD_NEUTER_RISKY_REPLAY
admin = client.get('/admin/login/', HTTP_HOST=get_admin_host())
assert admin.status_code == (200 if config.CONTROL_PLANE_ENABLED else 403)
if not config.CONTROL_PLANE_ENABLED:
assert client.get('/api/v1/docs', HTTP_HOST=get_api_host()).status_code == 403
assert client.post('/public/', HTTP_HOST=get_web_host()).status_code == 403
print('OK')
""",
mode=mode,
env_overrides={"BASE_URL": base},
)
def test_api_available_on_admin_and_api_hosts(self) -> None:
self._run(
"""
+10 -14
View File
@@ -47,7 +47,6 @@ _SUPERVISORD_ERRORS = (XmlRpcError, OSError, RuntimeError, TimeoutError)
_PROCESS_STATE_ERRORS = (DatabaseError, OSError, RuntimeError, ValueError, psutil.Error)
MIN_SERVER_WORKER_AVAILABLE_MEMORY_BYTES = 256 * 1024 * 1024
MIN_DEPENDENCY_INSTALL_AVAILABLE_MEMORY_BYTES = MIN_SERVER_WORKER_AVAILABLE_MEMORY_BYTES
MIN_CRAWL_AVAILABLE_MEMORY_BYTES = 512 * 1024 * 1024
def _shell_join(args: list[str]) -> str:
@@ -118,16 +117,6 @@ def require_dependency_install_memory(available_bytes: int | None = None) -> Non
)
def require_crawl_memory(available_bytes: int | None = None) -> None:
available_bytes = effective_available_memory_bytes() if available_bytes is None else available_bytes
_require_available_memory(
"archive a crawl",
" No crawl, runner, or Sonic workers were started.",
available_bytes,
MIN_CRAWL_AVAILABLE_MEMORY_BYTES,
)
def archivebox_cmd(*args: str) -> list[str]:
return [str(resolve_env_binary("archivebox")), *args]
@@ -1280,11 +1269,18 @@ def tail_multiple_worker_logs(log_files: list[str], follow=True, proc=None, keep
print()
# Log display needs a short refresh interval, but each ownership check
# queries the database and validates OS processes. Check leadership once
# per second even when workers continuously produce log output.
next_ownership_check = 0.0
try:
while follow:
if keep_running is not None and not keep_running():
print("\n[newer ArchiveBox process is now running the orchestrator and server]")
return "transferred"
now = time.monotonic()
if keep_running is not None and now >= next_ownership_check:
if not keep_running():
print("\n[newer ArchiveBox process is now running the orchestrator and server]")
return "transferred"
next_ownership_check = time.monotonic() + 1.0
# Check if the monitored process has exited
if proc is not None and proc.poll() is not None:
+29 -17
View File
@@ -193,6 +193,22 @@ echo "[*] Creating a temporary screenshot admin"
)
CREATED_TEMP_USER=1
# Configure the optional UI before the server snapshots its startup config.
OPENCODE_PORT="$(uv run --no-cache --project "$REPO_DIR" python - <<'PYPORT'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PYPORT
)"
(
cd "$DATA_DIR"
uv run --no-cache --project "$REPO_DIR" archivebox config --set \
OPENCODE_ENABLED=True "OPENCODE_PORT=$OPENCODE_PORT" TLSNOTARY_ENABLED=True
uv run --no-cache --project "$REPO_DIR" archivebox install opencode --binproviders=env,pnpm
)
echo "[*] Starting ArchiveBox on port $PORT"
(
cd "$DATA_DIR"
@@ -248,6 +264,10 @@ while [[ "$capture_index" -lt "${#VIEWS[@]}" ]]; do
capture_index=$((capture_index + 1))
slug="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '-' | sed 's/^-//; s/-$//')"
expected_plugin=""
expected_frame_text=""
if [[ "$capture_mode" == wait-frame-text:* ]]; then
expected_frame_text="${capture_mode#wait-frame-text:}"
fi
if [[ "$name" == "Snapshot View ("*")" && "$capture_mode" != "live-progress" && "$capture_mode" != "snapshot-collapsed" ]]; then
expected_plugin="${name#Snapshot View (}"
expected_plugin="${expected_plugin%)}"
@@ -407,6 +427,7 @@ PY
SCREENSHOT_VARIANTS_JSON="$output_variants" \
SCREENSHOT_COLLAPSE_FILTERS=1 \
SCREENSHOT_EXPECT_PLUGIN="$expected_plugin" \
SCREENSHOT_EXPECT_FRAME_TEXT="$expected_frame_text" \
node "$REPO_DIR/bin/take_screenshot.js" "$url" "$screenshot_path" >"$capture_dir/report.json"
view_timing_report="$capture_dir/report.json"
timing_report_path="$view_timing_report"
@@ -494,21 +515,6 @@ PY
CREATE_WEBHOOK="$CREATE_WEBHOOK" \
node "$REPO_DIR/bin/setup_ui_screenshot_data.js" "$ADMIN_BASE_URL" "$USERNAME"
OPENCODE_PORT="$(uv run --no-cache --project "$REPO_DIR" python - <<'PY'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PY
)"
(
cd "$DATA_DIR"
OPENCODE_PORT="$OPENCODE_PORT" uv run --no-cache --project "$REPO_DIR" archivebox manage shell --no-imports -c \
'import os; from archivebox.machine.models import Machine; Machine.from_json({"config": {"OPENCODE_ENABLED": True, "OPENCODE_PORT": int(os.environ["OPENCODE_PORT"])}})'
uv run --no-cache --project "$REPO_DIR" archivebox install opencode --binproviders=env,pnpm
)
SWEETING_CAPTURE_STARTED_AT="$( (
cd "$DATA_DIR"
uv run --no-cache --project "$REPO_DIR" archivebox manage shell --no-imports -c \
@@ -561,7 +567,7 @@ PY
VIEWS+=(
"Add URLs|$ADMIN_BASE_URL/add/|/add/|archivebox/core/views.py"
"Admin dashboard|$ADMIN_BASE_URL/admin/|/admin/|archivebox/core/admin_site.py"
"AI agent|$ADMIN_BASE_URL/admin/agent/|/admin/agent/|abx_plugins/plugins/opencode/views.py|wait-text:ArchiveBox AI Agent"
"AI agent|$ADMIN_BASE_URL/admin/agent/|/admin/agent/|archivebox/opencode/views.py|wait-text:ArchiveBox AI Agent"
"Snapshots table|$ADMIN_BASE_URL/admin/core/snapshot/|/admin/core/snapshot/|archivebox/core/admin_snapshots.py"
"Snapshots grid|$ADMIN_BASE_URL/admin/core/snapshot/grid/|/admin/core/snapshot/grid/|archivebox/templates/admin/snapshots_grid.html"
"Snapshot admin detail|$ADMIN_BASE_URL/admin/core/snapshot/$SNAPSHOT_ID/change/|/admin/core/snapshot/$SNAPSHOT_ID/change/|archivebox/core/admin_snapshots.py"
@@ -625,11 +631,17 @@ PY
SCREENSHOT_HEIGHT=1000 \
node "$REPO_DIR/bin/take_screenshot.js" "$LIVE_SNAPSHOT_VIEW_URL" "$CAPTURE_ROOT/snapshot-output-discovery.png" >"$SNAPSHOT_DISCOVERY_REPORT"
SNAPSHOT_OUTPUT_PLUGINS="$(UI_SCREENSHOT_DISCOVERY_REPORT="$SNAPSHOT_DISCOVERY_REPORT" uv run --no-cache --project "$REPO_DIR" python -c \
'import json, os; from urllib.parse import urlsplit; report=json.load(open(os.environ["UI_SCREENSHOT_DISCOVERY_REPORT"])); print("\n".join("{}\t{}".format(output["plugin"], "wait-replay:Nick Sweeting" if urlsplit(output["previewUrl"]).path.endswith(".wacz") else "") for output in report["checks"]["snapshotOutputs"]))')"
'import json, os; from urllib.parse import urlsplit; report=json.load(open(os.environ["UI_SCREENSHOT_DISCOVERY_REPORT"])); print("\n".join("{}\t{}".format(output["plugin"], "wait-frame-text:Verified · signature and archived response match" if output["plugin"] == "tlsnotary" else "wait-replay:Nick Sweeting" if urlsplit(output["previewUrl"]).path.endswith(".wacz") else "") for output in report["checks"]["snapshotOutputs"]))')"
if [[ -z "$SNAPSHOT_OUTPUT_PLUGINS" ]]; then
echo "[!] The Sweeting.me snapshot detail page exposed no selectable outputs" >&2
exit 1
fi
if ! printf '%s\n' "$SNAPSHOT_OUTPUT_PLUGINS" | cut -f1 | grep -qx tlsnotary; then
echo "[!] Live capture did not expose the required TLSNotary verifier output" >&2
tail -200 "$CAPTURE_ROOT/sweeting-live-capture.log" >&2
cat "$SNAPSHOT_DISCOVERY_REPORT" >&2
exit 1
fi
while IFS=$'\t' read -r plugin_name output_capture_mode; do
[[ -z "$plugin_name" ]] && continue
VIEWS+=("Snapshot View ($plugin_name)|$LIVE_SNAPSHOT_VIEW_URL#$plugin_name|/|archivebox/templates/core/snapshot.html|$output_capture_mode")
+7
View File
@@ -32,6 +32,7 @@ Environment:
SCREENSHOT_SNAPSHOT_VIEW Set to list or grid before loading the page
SCREENSHOT_SNAPSHOT_HEADER Set to expanded or collapsed before loading a snapshot detail page
SCREENSHOT_EXPECT_PLUGIN Require this snapshot output plugin to be selected
SCREENSHOT_EXPECT_FRAME_TEXT Require this text in the selected output frame
SCREENSHOT_EXPECT_LIVE_PROGRESS Require real progress bars and a loaded screencast frame
SCREENSHOT_COLLAPSE_FILTERS Set to 1 to keep admin filters out of screenshots
SCREENSHOT_RESET_FILTERS Set to 1 to clear the admin filter collapsed preference
@@ -231,6 +232,12 @@ async function main() {
}
});
}, { timeout: 45000, polling: 250 }).catch(() => {});
if (process.env.SCREENSHOT_EXPECT_FRAME_TEXT) {
const frameElement = await page.$('#main-frame');
const frame = await frameElement.contentFrame();
await frame.waitForFunction((text) => document.body.innerText.includes(text),
{ timeout: 45000 }, process.env.SCREENSHOT_EXPECT_FRAME_TEXT);
}
}
if (process.env.SCREENSHOT_EXPECT_LIVE_PROGRESS === '1') {
+2
View File
@@ -411,6 +411,8 @@ ArchiveBox splits its surfaces across three logical hosts: `admin.*` (Django adm
SingleFile output is served as ordinary HTML in every mode; it remains usable in no-JS modes because SingleFile removes the page's scripts during capture. ArchiveWeb.page/ReplayWeb.page and MHTML use their existing trusted preview templates. ArchiveBox always attempts to load these viewers, including when the incoming request is plain HTTP, because HTTPS may be terminated by an upstream proxy. Browser service-worker rules still require ReplayWeb.page to be reached through HTTPS or localhost for replay to initialize.
Trusted preview wrappers allow embedding by their own origin and the configured web/admin origins, so the collection UI can display snapshot-host previews without opening them to arbitrary embedding sites. PDF previews use a document frame rather than an object/embed, retaining `object-src 'none'` on the wrapper. Allowing a trusted viewer's scripts does not exempt the archived documents inside it from their replay policy; the MHTML viewer keeps its archived child document sandboxed without scripts.
> [!WARNING]
> Switching to any mode whose name starts with `unsafe-` or `danger-` is logged at startup and surfaces a banner in the UI. **Don't use these modes on a public hostname** — archived JavaScript will run on the same origin as your admin session.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.35rc449",
"version": "0.9.35rc460",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
+4 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.35rc449"
version = "0.9.35rc460"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@@ -79,8 +79,8 @@ dependencies = [
### Binary/Package Management
"abxbus==2.5.56", # direct imports only; version constrained by abx-dl -> abx-plugins
"abxpkg==1.12.117", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins[opencode]==1.12.236", # optional AI runtime clients are owned by the plugin
"abx-dl==1.12.274", # shared ArchiveBox downloader package
"abx-plugins[opencode]==1.12.241", # optional AI runtime clients are owned by the plugin
"abx-dl==1.12.281", # shared ArchiveBox downloader package
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
"browser-cookie3>=0.20.1",
@@ -342,7 +342,7 @@ Donate = "https://github.com/ArchiveBox/ArchiveBox/wiki/Donations"
[tool.bumpver]
current_version = "v0.9.35rc449"
current_version = "v0.9.35rc460"
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
Generated
+11 -11
View File
@@ -13,18 +13,18 @@ supported-markers = [
]
[options]
exclude-newer = "2026-09-11T22:03:43.68620639Z"
exclude-newer = "2026-09-12T23:27:00.678786196Z"
exclude-newer-span = "P5D"
[options.exclude-newer-package]
abxbus = { timestamp = "2026-09-16T22:03:42.686219334Z", span = "PT1S" }
abxbus = { timestamp = "2026-09-17T23:26:59.678836159Z", span = "PT1S" }
abx-plugins = "2100-01-01T00:00:00Z"
abx-dl = "2100-01-01T00:00:00Z"
abxpkg = "2100-01-01T00:00:00Z"
[[package]]
name = "abx-dl"
version = "1.12.274"
version = "1.12.281"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -38,14 +38,14 @@ dependencies = [
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/69/223fb0cfd106eda015a1a73daa58fc9aa87bb7b974094c5eadc65c15b693/abx_dl-1.12.274.tar.gz", hash = "sha256:58b7f85aec60db8233d4ea33a7683edf12be63fe32b8eccbd45d57edcab1a35b", size = 87257 }
sdist = { url = "https://files.pythonhosted.org/packages/38/9d/8e20554592e09e4db3d76a0d85cd03971a4c9cb44210610c35bd8a8b4515/abx_dl-1.12.281.tar.gz", hash = "sha256:37bcf7b38d85776d9e727e4e65e04d72ce355cf15efebc6e933a9c9df51f18d6", size = 88246 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/36/b12628d4ce600cbcd1c3ba87c6f76721a1c21e074e1f30782baf2eb2eb06/abx_dl-1.12.274-py3-none-any.whl", hash = "sha256:5481d25ab82da2449a3a0a47079fd37a8768f1175ed2068bef45392beb4ee684", size = 92246 },
{ url = "https://files.pythonhosted.org/packages/8f/e7/881a49809fd6a4f6bdcbfa09b47bdf3feb61e7e52faeb2a51e17d5b41840/abx_dl-1.12.281-py3-none-any.whl", hash = "sha256:4428ae92627819a9fa3d63bd601c460008aa2148aca92cf614e077e234e1d2db", size = 93261 },
]
[[package]]
name = "abx-plugins"
version = "1.12.236"
version = "1.12.241"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -54,9 +54,9 @@ dependencies = [
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/90/617fe56f24af0ed1a6bcc255233703c9378f49d381d2192eb76a0a1c9ad7/abx_plugins-1.12.236.tar.gz", hash = "sha256:1c0a23a4878787a64109c69160c71df74885c90e5fca9def7c13b8ea731b1add", size = 269546, upload-time = "2026-09-16T21:51:20.39Z" }
sdist = { url = "https://files.pythonhosted.org/packages/00/aa/b5a07dd20c60635b57c1fd11c325b47d38a0d2c2a1e203fadbf64933148a/abx_plugins-1.12.241.tar.gz", hash = "sha256:001ca84516b0e51169efb1cf87b73b8504bea4fad61c8bf40b7ee53a6cee0b9b", size = 293160, upload-time = "2026-09-17T23:05:59.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/ed/ac9682624eea2604cb3d35ac6f952c92c95e52c064401044d43c3df5864a/abx_plugins-1.12.236-py3-none-any.whl", hash = "sha256:0bf8738c64d27dd88d2ac4e91c4c85504352cc1a47bdc9aa863a12a9b7f8a5da", size = 424591, upload-time = "2026-09-16T21:51:21.816Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b9/94c0317de5ab364a91c1e2cdd16951022d37da3f2a2c6020b8f79a35f82f/abx_plugins-1.12.241-py3-none-any.whl", hash = "sha256:27170b2a155d7dcda43e80bc5e5209583a045b3b9896ca57c5b8085f24d6e9e1", size = 457274, upload-time = "2026-09-17T23:05:57.452Z" },
]
[package.optional-dependencies]
@@ -127,7 +127,7 @@ wheels = [
[[package]]
name = "archivebox"
version = "0.9.35rc449"
version = "0.9.35rc460"
source = { editable = "." }
dependencies = [
{ name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -226,8 +226,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "abx-dl", specifier = "==1.12.274" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.12.236" },
{ name = "abx-dl", specifier = "==1.12.281" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.12.241" },
{ name = "abxbus", specifier = "==2.5.56" },
{ name = "abxpkg", specifier = "==1.12.117" },
{ name = "archivebox", extras = ["sonic", "ldap", "debug"], marker = "extra == 'all'" },