diff --git a/archivebox/config/collection.py b/archivebox/config/collection.py index 549b139a..61c03b9b 100644 --- a/archivebox/config/collection.py +++ b/archivebox/config/collection.py @@ -194,6 +194,12 @@ def _coerce_from_str_dict(file_config: dict[str, str]) -> dict[str, Any]: decoded[key] = json.loads(raw) except (TypeError, ValueError): continue + if not str(decoded.get("BASE_URL") or "").strip(): + from archivebox.config.common import base_url_from_legacy_server_config + + legacy_base_url = base_url_from_legacy_server_config(decoded) + if legacy_base_url: + decoded["BASE_URL"] = legacy_base_url return decoded diff --git a/archivebox/config/common.py b/archivebox/config/common.py index c3bc09cf..b4b3d7ea 100644 --- a/archivebox/config/common.py +++ b/archivebox/config/common.py @@ -1245,6 +1245,10 @@ def get_config( ) config = ArchiveBoxConfig.model_validate(config_data) + if config.SERVER_SECURITY_MODE == "auto": + base_host = (urlparse(config.BASE_URL if "://" in config.BASE_URL else f"//{config.BASE_URL}").hostname or "").lower() + if base_host.endswith(".localhost"): + config = config.model_copy(update={"SERVER_SECURITY_MODE": "safe-subdomains-fullreplay"}) for key in explicit_plugin_enabled_keys: if key in config_data: setattr(config, key, config_data[key]) diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py index 2ebd004f..87a9f62a 100644 --- a/archivebox/core/admin_archiveresults.py +++ b/archivebox/core/admin_archiveresults.py @@ -413,6 +413,7 @@ class ArchiveResultAdmin(BaseModelAdmin): "cmd_version", "pwd", "cmd_str", + "admin_actions", "snapshot_info", "tags_str", "created_at", @@ -438,7 +439,7 @@ class ArchiveResultAdmin(BaseModelAdmin): ( "Snapshot", { - "fields": ("snapshot", "snapshot_info", "tags_str"), + "fields": ("snapshot", "snapshot_info", "tags_str", "admin_actions"), "classes": ("card", "wide"), }, ), @@ -630,6 +631,38 @@ class ArchiveResultAdmin(BaseModelAdmin): self.get_output_zip_url(result), ) + @admin.display(description="") + def admin_actions(self, result): + return format_html( + """ +
+ """, + self.get_output_view_url(result), + self.get_output_files_url(result), + self.get_output_zip_url(result), + self.get_snapshot_view_url(result), + ) + @admin.display( description="Snapshot", ordering="snapshot__url", diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py index ab83df38..afc6122d 100644 --- a/archivebox/core/middleware.py +++ b/archivebox/core/middleware.py @@ -89,9 +89,10 @@ def AdminCookieIsolationMiddleware(get_response): response = get_response(request) config = request.__dict__.get("archivebox_config") - if config is None: - config = get_config(resolve_plugins=False) - request.archivebox_config = config + if config is None or config.SERVER_SECURITY_MODE == "auto": + from archivebox.config.common import get_request_config + + config = get_request_config(request, resolve_plugins=False) if not config.USES_SUBDOMAIN_ROUTING: return response @@ -168,9 +169,17 @@ def ServerSecurityModeMiddleware(get_response): def middleware(request): config = request.__dict__.get("archivebox_config") - if config is None: - config = get_config(resolve_plugins=False) - request.archivebox_config = config + if config is None or config.SERVER_SECURITY_MODE == "auto": + base_config = config or get_config(resolve_plugins=False) + if base_config.SERVER_SECURITY_MODE == "auto" and request.method.upper() not in allowed_methods: + request_host, _request_port = split_host_port((request.get_host() or "").lower()) + base_host, _base_port = split_host_port(get_base_host(config=base_config)) + if base_host and request_host != base_host: + return HttpResponseForbidden("ArchiveBox is running with the control plane disabled on this host.") + + from archivebox.config.common import get_request_config + + config = get_request_config(request, resolve_plugins=False) if config.CONTROL_PLANE_ENABLED: return get_response(request) diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py index d60d9843..c2b14bf3 100644 --- a/archivebox/core/settings.py +++ b/archivebox/core/settings.py @@ -348,6 +348,12 @@ if not _secret_persisted: logger.debug("Unable to persist generated SECRET_KEY", exc_info=True) ALLOWED_HOSTS = [host.strip() for host in CONFIG.ALLOWED_HOSTS.split(",") if host.strip()] +if (CONFIG.SERVER_SECURITY_MODE == "auto" or CONFIG.USES_SUBDOMAIN_ROUTING) and "*" not in ALLOWED_HOSTS: + # ArchiveBox owns the effective host policy at request time in auto and + # subdomain modes: canonical hosts keep the normal app surface, while + # alternate hosts are downgraded/rejected by get_request_config()/middleware. + # Django has to admit the Host header first for that existing boundary to run. + ALLOWED_HOSTS.append("*") CSRF_TRUSTED_ORIGINS = list({origin.strip() for origin in CONFIG.CSRF_TRUSTED_ORIGINS.split(",") if origin.strip()}) admin_base_url = normalize_base_url(get_admin_base_url()) diff --git a/archivebox/core/views.py b/archivebox/core/views.py index d154ac25..605229cd 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -912,7 +912,7 @@ def _plugin_full_preview_response( response.headers["Content-Security-Policy"] = ( "default-src 'self' data: blob:; " "script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; " - "style-src 'self' 'unsafe-inline' data: blob:; " + "style-src 'unsafe-inline' data: blob: 'self'; " "connect-src 'self' data: blob:; " "img-src 'self' data: blob:; " "media-src 'self' data: blob:; " diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py index 5d5b6ec2..c2182342 100755 --- a/archivebox/machine/models.py +++ b/archivebox/machine/models.py @@ -238,9 +238,18 @@ class Machine(ModelWithHealthStats): try: _CURRENT_MACHINE = cls.objects.get(guid=host_guid) except cls.DoesNotExist: + config = {} + try: + from archivebox.config.collection import _coerce_from_str_dict, _load_file_config_dict + + file_config, _file_mtime = _load_file_config_dict() + config = _coerce_from_str_dict(file_config) + except Exception: + config = {} _CURRENT_MACHINE = cls.objects.create( guid=host_guid, hostname=socket.gethostname(), + config=config, **get_os_info(), **get_vm_info(), stats=get_host_stats(), diff --git a/archivebox/plugins/hooks.py b/archivebox/plugins/hooks.py index eeeaa6d2..fd03f58e 100644 --- a/archivebox/plugins/hooks.py +++ b/archivebox/plugins/hooks.py @@ -44,6 +44,7 @@ __package__ = "archivebox.plugins" import json import os +import sys from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard, runtime_checkable @@ -326,10 +327,11 @@ def run_hook( if not script.is_file(): raise FileNotFoundError(f"Hook script not found: {script}") - # Python hooks carry their runtime contract in the shebang - # (usually `abxpkg run --script python3`), so execute them directly. - # For shell/JS hooks we still dispatch through the conventional - # interpreter because those hooks do not need per-script Python env setup. + # ArchiveBox resolves hook dependencies before execution and exports the + # resolved *_BINARY env values below. Run Python hooks in the current Python + # environment so per-snapshot hook execution does not repeatedly pay the + # abxpkg script bootstrap cost from the standalone shebang. + # Shell/JS hooks still dispatch through abxpkg-resolved interpreters. ext = script.suffix.lower() if ext == ".sh": bash_projection = Path(hook_config["ABXPKG_LIB_DIR"]).expanduser() / "env" / "bin" / "bash" @@ -337,7 +339,7 @@ def run_hook( raise RuntimeError(f"Bash must be resolved by abxpkg into {bash_projection}") cmd = [str(bash_projection), str(script)] elif ext == ".py": - cmd = [str(script)] + cmd = [sys.executable, str(script)] elif ext == ".js": node_projection = Path(hook_config["ABXPKG_LIB_DIR"]).expanduser() / "env" / "bin" / "node" if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK): @@ -377,7 +379,6 @@ def run_hook( env.pop("PLUGINS", None) env["DATA_DIR"] = str(CONSTANTS.DATA_DIR) env["LIBRARY_VERSION"] = VERSION - env.setdefault("ABXPKG_FAST_SCRIPT", "1") env.setdefault("MACHINE_ID", os.environ.get("MACHINE_ID", CONSTANTS.MACHINE_ID)) snap_dir = hook_config.get("SNAP_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.SNAPSHOTS_DIR_NAME) crawl_dir = hook_config.get("CRAWL_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.CRAWLS_DIR_NAME) diff --git a/archivebox/search/backends.py b/archivebox/search/backends.py index 135faad7..fba5ab5a 100644 --- a/archivebox/search/backends.py +++ b/archivebox/search/backends.py @@ -16,12 +16,13 @@ def search_backend_env(config: dict[str, Any] | None = None, **config_kwargs: An config = config or get_config(**config_kwargs) updates = {} for key, value in config.items(): - if not str(key).startswith("SEARCH_BACKEND_"): + key = str(key) + if not (key.startswith("SEARCH_BACKEND_") or key.endswith("_BINARY")): continue if value is None: continue if isinstance(value, (str, int, float, bool, os.PathLike)): - updates[str(key)] = str(value) + updates[key] = str(value) previous = {key: os.environ.get(key) for key in updates} os.environ.update(updates) try: diff --git a/archivebox/templates/core/snapshot.html b/archivebox/templates/core/snapshot.html index d5c06afe..8d424989 100644 --- a/archivebox/templates/core/snapshot.html +++ b/archivebox/templates/core/snapshot.html @@ -1665,7 +1665,7 @@ {% endif %}