From 05350846aba81103a8fdaf70d72eaea87b57fcbd Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Sat, 25 Jul 2026 14:48:55 -0700 Subject: [PATCH] Sync ArchiveBox UI and setup updates --- .github/workflows/deploy-publicsite.yml | 27 + .gitignore | 2 + archivebox/base_models/admin.py | 89 +- archivebox/config/common.py | 91 +- archivebox/config/views.py | 51 +- archivebox/core/admin_archiveresults.py | 112 +-- archivebox/core/admin_site.py | 23 +- archivebox/core/context_processors.py | 14 +- archivebox/core/forms.py | 13 +- archivebox/core/models.py | 224 +++-- archivebox/core/routes_util.py | 19 +- archivebox/core/setup_wizard.py | 68 ++ archivebox/core/templatetags/core_tags.py | 141 ++-- archivebox/core/views.py | 213 +++-- archivebox/crawls/admin.py | 43 +- archivebox/machine/admin.py | 42 +- archivebox/mcp/server.py | 683 +++++++++------ archivebox/misc/serve_static.py | 147 ++-- archivebox/plugins/views.py | 91 +- archivebox/templates/admin/actions.html | 4 +- .../admin/archivebox_change_form.html | 260 ++++++ .../admin/auth/user/change_form.html | 41 +- archivebox/templates/admin/base.html | 94 ++- .../templates/admin/change_list_panel.html | 2 +- .../admin/core/archiveresult/change_list.html | 2 +- .../templates/admin/core/tag/change_form.html | 2 +- .../admin/crawls/crawl/change_form.html | 46 +- .../admin/personas/persona/change_form.html | 2 +- .../templates/admin/snapshots_grid.html | 46 +- archivebox/templates/core/add.html | 2 +- archivebox/templates/core/base.html | 2 +- archivebox/templates/core/navigation.html | 47 +- archivebox/templates/core/public_index.html | 76 +- archivebox/templates/core/setup_wizard.html | 121 +++ archivebox/templates/core/snapshot.html | 603 ++++++++++--- .../core/system_warnings_banner.html | 160 ++-- archivebox/templates/static/add.css | 75 ++ archivebox/templates/static/admin.css | 796 +++++++++++++++--- .../static/admin/crawls/crawl_change.css | 22 + archivebox/templates/static/setup_wizard.css | 56 ++ archivebox/templates/static/setup_wizard.js | 405 +++++++++ archivebox/tests/test_migrations_07_to_09.py | 181 +++- archivebox/tests/test_ui_add_view.py | 48 +- .../tests/test_ui_admin_config_widget.py | 230 +++++ archivebox/tests/test_ui_admin_crawl.py | 60 +- bin/collect_ui_screenshots.sh | 485 +++++++++++ bin/generate_ui_screenshot_gallery.py | 263 ++++++ bin/setup_ui_screenshot_data.js | 120 +++ bin/take_screenshot.js | 124 ++- docker-compose.yml | 4 +- docs/Configuration.md | 16 +- docs/Screenshots.md | 725 ++++++++++++++++ docs/Security-Overview.md | 2 +- docs/_Sidebar.md | 1 + publicsite/index.html | 2 +- 55 files changed, 5838 insertions(+), 1380 deletions(-) mode change 100755 => 100644 archivebox/core/models.py create mode 100644 archivebox/core/setup_wizard.py create mode 100644 archivebox/templates/admin/archivebox_change_form.html create mode 100644 archivebox/templates/core/setup_wizard.html create mode 100644 archivebox/templates/static/setup_wizard.css create mode 100644 archivebox/templates/static/setup_wizard.js create mode 100755 bin/collect_ui_screenshots.sh create mode 100755 bin/generate_ui_screenshot_gallery.py create mode 100755 bin/setup_ui_screenshot_data.js create mode 100644 docs/Screenshots.md diff --git a/.github/workflows/deploy-publicsite.yml b/.github/workflows/deploy-publicsite.yml index c42ded47..3813dbd4 100644 --- a/.github/workflows/deploy-publicsite.yml +++ b/.github/workflows/deploy-publicsite.yml @@ -6,6 +6,13 @@ on: - dev paths: - publicsite/** + - archivebox/** + - bin/collect_ui_screenshots.* + - bin/generate_ui_screenshot_gallery.py + - bin/take_screenshot.js + - docs/Screenshots.md + - pyproject.toml + - uv.lock - .github/workflows/deploy-publicsite.yml workflow_dispatch: @@ -30,6 +37,26 @@ jobs: with: fetch-depth: 1 + - name: Checkout screenshot plugin + uses: actions/checkout@v4 + with: + repository: ArchiveBox/abx-plugins + path: .ui-screenshot-deps/abx-plugins + + - name: Setup Python and uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install ArchiveBox and screenshot dependencies + run: uv sync --frozen + + - name: Build UI screenshot gallery + run: bin/collect_ui_screenshots.sh + env: + ABX_PLUGINS_DIR: ${{ github.workspace }}/.ui-screenshot-deps/abx-plugins/abx_plugins/plugins + BASE_URL: http://archivebox.localhost:8000 + - name: Setup Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 diff --git a/.gitignore b/.gitignore index 1f6cdd50..b9c50741 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ __pycache__/ .mypy_cache/ .eggs/ tests/out/ +/docs/screenshots/ +/publicsite/screenshots/ # Coverage .coverage diff --git a/archivebox/base_models/admin.py b/archivebox/base_models/admin.py index 887dae39..e7eaaf3b 100644 --- a/archivebox/base_models/admin.py +++ b/archivebox/base_models/admin.py @@ -5,11 +5,11 @@ __package__ = "archivebox.base_models" import json import uuid from collections.abc import Mapping -from typing import NotRequired, TypedDict, cast +from typing import ClassVar, NotRequired, TypedDict, cast from django import forms from django.contrib import admin -from django.db import models +from django.db import DatabaseError, models from django.forms.renderers import BaseRenderer from django.http import HttpRequest, QueryDict from django.urls import path, register_converter @@ -68,10 +68,10 @@ class KeyValueWidget(forms.Widget): template_name = "" # We render manually class Media: - css = { + css: ClassVar[dict[str, list[str]]] = { "all": [], } - js = [] + js: ClassVar[list[str]] = [] def _get_config_options(self) -> dict[str, ConfigOption]: """Get available config options from plugins.""" @@ -94,7 +94,7 @@ class KeyValueWidget(forms.Widget): option[schema_key] = schema[schema_key] options[key] = option return options - except Exception: + except (ImportError, KeyError, TypeError, ValueError): return {} def _parse_value(self, value: object) -> dict[str, object]: @@ -644,41 +644,64 @@ class KeyValueWidget(forms.Widget): window.updateHiddenField_{widget_id} = updateHiddenField_{widget_id}; - function focusConfigKeyFromHash_{widget_id}() {{ - // Deep-link affordance: ``…/change/#SOME_KEY`` jumps directly - // to (or creates) the matching row in this editor. Used by - // the in-banner "pin via admin" link and the - // ``→ Edit in Machine.config`` shortcut on the live - // config detail page. - var hash = (window.location.hash || '').replace(/^#/, '').trim(); - if (!hash || !/^[A-Z][A-Z0-9_]*$/.test(hash)) {{ - return; - }} + function configRowForKey_{widget_id}(key) {{ var container = document.getElementById('{widget_id}_rows'); if (!container) {{ - return; + return null; }} var match = null; container.querySelectorAll('.key-value-row').forEach(function(row) {{ if (match) {{ return; }} var keyInput = row.querySelector('.kv-key'); - if (keyInput && keyInput.value.trim() === hash) {{ + if (keyInput && keyInput.value.trim() === key) {{ match = row; }} }}); if (!match) {{ - // No existing row for this key — prepopulate one with the - // key filled in but value left blank so the operator just - // types/pastes the value and hits save. window.addKeyValueRow_{widget_id}(); var rows = container.querySelectorAll('.key-value-row'); match = rows[rows.length - 1]; var keyInput = match.querySelector('.kv-key'); if (keyInput) {{ - keyInput.value = hash; + keyInput.value = key; keyInput.dispatchEvent(new Event('input', {{ bubbles: true }})); }} }} + return match; + }} + + function prefillConfigFromQuery_{widget_id}() {{ + var params = new URLSearchParams(window.location.search); + var consumedConfigKeys = []; + params.forEach(function(value, key) {{ + if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !configMeta_{widget_id}[key]) {{ + return; + }} + consumedConfigKeys.push(key); + var match = configRowForKey_{widget_id}(key); + var valueInput = match && match.querySelector('.kv-value'); + if (valueInput) {{ + valueInput.value = value; + valueInput.dispatchEvent(new Event('input', {{ bubbles: true }})); + }} + }}); + updateHiddenField_{widget_id}(); + if (consumedConfigKeys.length) {{ + var cleanUrl = new URL(window.location.href); + consumedConfigKeys.forEach(function(key) {{ cleanUrl.searchParams.delete(key); }}); + window.history.replaceState(null, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash); + }} + }} + + function focusConfigKeyFromHash_{widget_id}() {{ + // Deep-link affordance: ``…/change/#SOME_KEY`` jumps directly + // to (or creates) the matching row in this editor. Used by + // the setup wizard and live-config detail pages. + var hash = (window.location.hash || '').replace(/^#/, '').trim(); + if (!hash || !/^[A-Z][A-Z0-9_]*$/.test(hash)) {{ + return; + }} + var match = configRowForKey_{widget_id}(hash); if (!match) {{ return; }} @@ -700,12 +723,14 @@ class KeyValueWidget(forms.Widget): // Initialize on load document.addEventListener('DOMContentLoaded', function() {{ initializeRows_{widget_id}(); + prefillConfigFromQuery_{widget_id}(); updateHiddenField_{widget_id}(); focusConfigKeyFromHash_{widget_id}(); }}); // Also run immediately in case DOM is already ready if (document.readyState !== 'loading') {{ initializeRows_{widget_id}(); + prefillConfigFromQuery_{widget_id}(); updateHiddenField_{widget_id}(); focusConfigKeyFromHash_{widget_id}(); }} @@ -826,7 +851,7 @@ class ConfigEditorMixin(admin.ModelAdmin): if change and obj.pk and obj.config is not None: try: stored = type(obj).objects.filter(pk=obj.pk).values_list("config", flat=True).first() or {} - except Exception: + except (AttributeError, DatabaseError, TypeError, ValueError): stored = {} if isinstance(stored, dict): new_config = dict(obj.config or {}) @@ -845,6 +870,20 @@ class BaseModelAdmin(DjangoObjectActions, admin.ModelAdmin): list_display = ("id", "created_at", "created_by") readonly_fields = ("id", "created_at", "modified_at") show_search_mode_selector = False + change_form_template = "admin/archivebox_change_form.html" + + def get_admin_toolbar_actions(self, request, obj): + """Return extra action button dicts for the shared change-form toolbar. + + See ``templates/admin/includes/archivebox_toolbar.html`` for the + accepted keys. Default: no extras (toolbar renders Save/History/Delete + plus any ``django-object-actions`` change_actions). + """ + return [] + + def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None): + context.setdefault("archivebox_admin_actions", self.get_admin_toolbar_actions(request, obj)) + return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj) def get_default_search_mode(self) -> str: # The shared changelist template always asks every admin for a default @@ -877,9 +916,9 @@ class BaseModelAdmin(DjangoObjectActions, admin.ModelAdmin): """ info = self.opts.app_label, self.opts.model_name object_routes = [ - path("/history/", self.admin_site.admin_view(self.history_view), name="%s_%s_history" % info), - path("/delete/", self.admin_site.admin_view(self.delete_view), name="%s_%s_delete" % info), - path("/change/", self.admin_site.admin_view(self.change_view), name="%s_%s_change" % info), + path("/history/", self.admin_site.admin_view(self.history_view), name="{}_{}_history".format(*info)), + path("/delete/", self.admin_site.admin_view(self.delete_view), name="{}_{}_delete".format(*info)), + path("/change/", self.admin_site.admin_view(self.change_view), name="{}_{}_change".format(*info)), ] # Append after super().get_urls() so our patterns are the # *last-registered* ones with the canonical admin URL names — Django's diff --git a/archivebox/config/common.py b/archivebox/config/common.py index 01c4c9ec..69eed925 100644 --- a/archivebox/config/common.py +++ b/archivebox/config/common.py @@ -2,32 +2,31 @@ from __future__ import annotations __package__ = "archivebox.config" +import inspect import json import os import re import secrets -import sys import shutil -import inspect -from functools import lru_cache +import sys from collections.abc import Mapping from datetime import timedelta -from typing import Any, ClassVar, cast +from functools import cache, lru_cache from pathlib import Path -from urllib.parse import quote +from typing import Any, ClassVar, cast +from urllib.parse import quote, urlparse -from rich.console import Console +from abx_plugins.plugins.base.utils import BASE_CONFIG_PATH, build_config_model, resolve_plugin_configs from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator from pydantic_settings import SettingsConfigDict -from abx_plugins.plugins.base.utils import BASE_CONFIG_PATH, build_config_model, resolve_plugin_configs +from rich.console import Console -from archivebox.config.configset import BaseConfigSet, IniConfigSettingsSource -from archivebox.config.configset import COMPUTED_CONFIG_KEYS +from archivebox.config.configset import COMPUTED_CONFIG_KEYS, BaseConfigSet, IniConfigSettingsSource from .constants import CONSTANTS from .ldap import LDAPConfig -from .version import get_COMMIT_HASH, get_BUILD_TIME, VERSION from .permissions import IN_DOCKER +from .version import VERSION, get_BUILD_TIME, get_COMMIT_HASH ConfigOverrides = Mapping[str, object] ConfigPayload = dict[str, object] @@ -67,6 +66,40 @@ def permissions_from_legacy_public_flags(raw_config: Mapping[str, object]) -> st return None +def base_url_from_legacy_server_config(raw_config: Mapping[str, object]) -> str: + """Derive the current canonical URL from the pre-0.9 server settings.""" + + def normalize(value: object) -> str: + raw = str(value or "").strip() + if not raw: + return "" + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + return f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else "" + + for key in ("ARCHIVE_BASE_URL", "ADMIN_BASE_URL"): + candidate = normalize(raw_config.get(key)) + if candidate: + return candidate + + listen_host = str(raw_config.get("LISTEN_HOST") or "").strip() + if listen_host and listen_host.lower() != "archivebox.localhost:8000": + return normalize(listen_host) + + csrf_origins = [normalize(origin) for origin in str(raw_config.get("CSRF_TRUSTED_ORIGINS") or "").split(",")] + csrf_origins = [origin for origin in csrf_origins if origin] + nondefault_csrf_origins = [origin for origin in csrf_origins if origin != "http://admin.archivebox.localhost:8000"] + if len(nondefault_csrf_origins) == 1: + return nondefault_csrf_origins[0] + + allowed_hosts = [host.strip() for host in str(raw_config.get("ALLOWED_HOSTS") or "").split(",") if host.strip() != "*"] + if len(allowed_hosts) == 1: + return normalize(allowed_hosts[0]) + + if len(csrf_origins) == 1: + return csrf_origins[0] + return normalize(listen_host) + + def resolve_delete_after_config_value(*configs: Mapping[str, Any] | None) -> str: for config in configs: if config is None: @@ -272,6 +305,7 @@ class ServerConfig(BaseConfigSet): _scope: str = PrivateAttr(default=_SCOPE_SERVER) SERVER_SECURITY_MODES: ClassVar[tuple[str, ...]] = ( + "auto", "safe-subdomains-fullreplay", "safe-onedomain-nojsreplay", "unsafe-onedomain-noadmin", @@ -283,7 +317,7 @@ class ServerConfig(BaseConfigSet): BASE_URL: str = Field(default="") ALLOWED_HOSTS: str = Field(default="*") CSRF_TRUSTED_ORIGINS: str = Field(default="") - SERVER_SECURITY_MODE: str = Field(default="safe-subdomains-fullreplay") + SERVER_SECURITY_MODE: str = Field(default="auto") SNAPSHOTS_PER_PAGE: int = Field(default=50, ge=1) FOOTER_INFO: str = Field( @@ -308,11 +342,21 @@ class ServerConfig(BaseConfigSet): @property def USES_SUBDOMAIN_ROUTING(self) -> bool: - return self.SERVER_SECURITY_MODE == "safe-subdomains-fullreplay" + if self.SERVER_SECURITY_MODE == "safe-subdomains-fullreplay": + return True + if self.SERVER_SECURITY_MODE != "auto": + return False + + base_host = (urlparse(self.BASE_URL if "://" in self.BASE_URL else f"//{self.BASE_URL}").hostname or "").lower() + if base_host: + return base_host.endswith(".localhost") + + bind_host = (urlparse(f"//{self.BIND_ADDR}").hostname or "").lower() + return bind_host in {"", "0.0.0.0", "127.0.0.1", "::", "::1", "localhost"} @property def ENABLES_FULL_JS_REPLAY(self) -> bool: - return self.SERVER_SECURITY_MODE in ( + return self.USES_SUBDOMAIN_ROUTING or self.SERVER_SECURITY_MODE in ( "safe-subdomains-fullreplay", "unsafe-onedomain-noadmin", "danger-onedomain-fullreplay", @@ -328,7 +372,7 @@ class ServerConfig(BaseConfigSet): @property def SHOULD_NEUTER_RISKY_REPLAY(self) -> bool: - return self.SERVER_SECURITY_MODE == "safe-onedomain-nojsreplay" + return self.SERVER_SECURITY_MODE in ("auto", "safe-onedomain-nojsreplay") @property def IS_UNSAFE_MODE(self) -> bool: @@ -643,7 +687,7 @@ class ArchiveBoxBaseConfig( @classmethod def _plugin_field_scope(cls, key: str) -> str | None: scope = None - for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items(): + for schema in PLUGIN_CONFIG_SCHEMAS.values(): properties = schema.get("properties") if isinstance(schema, dict) else None if not isinstance(properties, dict) or key not in properties: continue @@ -655,7 +699,7 @@ class ArchiveBoxBaseConfig( return scope @classmethod - @lru_cache(maxsize=None) + @cache def scope_for_key(cls, key: str) -> str: for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items(): properties = schema.get("properties") if isinstance(schema, dict) else None @@ -995,8 +1039,12 @@ def get_request_config(request: Any, *, resolve_plugins: bool = False) -> Archiv request_config_resolves_plugins = bool(request_state.get("_archivebox_config_resolves_plugins", False)) if request_config is None or (resolve_plugins and not request_config_resolves_plugins): request_config = get_config(resolve_plugins=resolve_plugins) - request.archivebox_config = request_config request._archivebox_config_resolves_plugins = resolve_plugins + if request_config.SERVER_SECURITY_MODE == "auto": + request_host = (urlparse(f"//{request.get_host()}").hostname or "").lower().rstrip(".") + effective_mode = "safe-subdomains-fullreplay" if request_host.endswith(".localhost") else "safe-onedomain-nojsreplay" + request_config = request_config.model_copy(update={"SERVER_SECURITY_MODE": effective_mode}) + request.archivebox_config = request_config return request_config @@ -1036,7 +1084,7 @@ def get_config( from archivebox.machine.models import Machine machine = Machine.current() - except Exception: + except (ImportError, RuntimeError, TypeError, ValueError): machine = None if persona is None and crawl is not None: @@ -1062,9 +1110,14 @@ def get_config( config_data.update( normalize_runtime_config(base_config_model.model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False), ) - legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ}) + legacy_config = {**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ} + legacy_permissions = permissions_from_legacy_public_flags(legacy_config) if legacy_permissions: config_data["PERMISSIONS"] = legacy_permissions + if not str(config_data.get("BASE_URL") or "").strip(): + legacy_base_url = base_url_from_legacy_server_config(legacy_config) + if legacy_base_url: + config_data["BASE_URL"] = legacy_base_url scope_overrides: ConfigPayload = {} diff --git a/archivebox/config/views.py b/archivebox/config/views.py index 5ca38483..9d19cd7c 100644 --- a/archivebox/config/views.py +++ b/archivebox/config/views.py @@ -1,25 +1,32 @@ __package__ = "archivebox.config" -import os import inspect +import os from pathlib import Path from typing import Any from urllib.parse import quote, urlencode + +from admin_data_views.typing import ItemContext, SectionData, TableContext +from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view from django.http import HttpRequest from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe -from admin_data_views.typing import TableContext, ItemContext, SectionData -from admin_data_views.utils import render_with_table_view, render_with_item_view, ItemLink - from archivebox.config import CONSTANTS -from archivebox.misc.util import parse_date - from archivebox.machine.models import Binary +from archivebox.misc.util import parse_date ENVIRONMENT_BINARIES_BASE_URL = "/admin/environment/binaries/" INSTALLED_BINARIES_BASE_URL = "/admin/machine/binary/" +LOG_DETAIL_TAIL_BYTES = 100_000 + + +def _read_text_tail(path: Path, max_bytes: int = LOG_DETAIL_TAIL_BYTES) -> str: + with path.open("rb") as log_file: + size = path.stat().st_size + log_file.seek(max(size - max_bytes, 0)) + return log_file.read(max_bytes).decode("utf-8", errors="replace") def is_superuser(request: HttpRequest) -> bool: @@ -87,7 +94,7 @@ def obj_to_yaml(obj: Any, indent: int = 0) -> str: return f" {obj}" elif isinstance(obj, (int, float, bool)): - return f" {str(obj)}" + return f" {obj!s}" elif callable(obj): source = ( @@ -98,7 +105,7 @@ def obj_to_yaml(obj: Any, indent: int = 0) -> str: return f" {indent_str} " + source.replace("\n", f"\n{indent_str} ") else: - return f" {str(obj)}" + return f" {obj!s}" def _binary_sort_key(binary: Binary) -> tuple[int, int, int, Any]: @@ -300,7 +307,7 @@ def worker_list_view(request: HttpRequest, **kwargs) -> TableContext: pid_to_process_id[row.pid] = str(row.id) pid_to_process_type[row.pid] = row.process_type - except Exception: + except (ImportError, RuntimeError, TypeError, ValueError): pass def _pid_cell(pid_value: int | None, uptime_str: str = ""): @@ -341,7 +348,7 @@ def worker_list_view(request: HttpRequest, **kwargs) -> TableContext: hours, remainder = divmod(seconds, 3600) minutes, secs = divmod(remainder, 60) supervisor_uptime = f"{hours}:{minutes:02d}:{secs:02d}" - except Exception: + except (ImportError, RuntimeError, TypeError, ValueError): try: from archivebox.machine.models import Machine, Process @@ -360,7 +367,7 @@ def worker_list_view(request: HttpRequest, **kwargs) -> TableContext: hours, remainder = divmod(seconds, 3600) minutes, secs = divmod(remainder, 60) supervisor_uptime = f"{hours}:{minutes:02d}:{secs:02d}" - except Exception: + except (RuntimeError, TypeError, ValueError): pass rows["PID"].append(_pid_cell(supervisor_pid if isinstance(supervisor_pid, int) else None, supervisor_uptime)) @@ -415,7 +422,7 @@ def worker_list_view(request: HttpRequest, **kwargs) -> TableContext: def worker_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: assert is_superuser(request), "Must be a superuser to view configuration settings." - from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker, get_sock_file, CONFIG_FILE_NAME + from archivebox.workers.supervisord_util import CONFIG_FILE_NAME, get_existing_supervisord_process, get_sock_file, get_worker SOCK_FILE = get_sock_file() CONFIG_FILE = SOCK_FILE.parent / CONFIG_FILE_NAME @@ -438,8 +445,12 @@ def worker_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: if key == "supervisord": relevant_config = CONFIG_FILE.read_text() - relevant_logs = str(supervisor.readLog(0, 10_000_000)) - start_ts = [line for line in relevant_logs.split("\n") if "RPC interface 'supervisor' initialized" in line][-1].split(",", 1)[0] + supervisor_log = CONSTANTS.LOGS_DIR / "supervisord.log" + with supervisor_log.open("rb") as log_file: + startup_logs = log_file.read(LOG_DETAIL_TAIL_BYTES).decode("utf-8", errors="replace") + relevant_logs = _read_text_tail(supervisor_log) + startup_lines = [line for line in startup_logs.split("\n") if "RPC interface 'supervisor' initialized" in line] + start_ts = startup_lines[-1].split(",", 1)[0] if startup_lines else "" start_dt = parse_date(start_ts) uptime = str(timezone.now() - start_dt).split(".")[0] if start_dt else "" supervisor_state = supervisor.getState() @@ -458,7 +469,7 @@ def worker_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: worker_data = get_worker(supervisor, key) proc = worker_data if isinstance(worker_data, dict) else {} relevant_config = next((config for config in all_config if config.get("name") == key), {}) - log_result = supervisor.tailProcessStdoutLog(key, 0, 10_000_000) + log_result = supervisor.tailProcessStdoutLog(key, -LOG_DETAIL_TAIL_BYTES, LOG_DETAIL_TAIL_BYTES) relevant_logs = str(log_result[0] if isinstance(log_result, tuple) else log_result) section: SectionData = { @@ -474,7 +485,7 @@ def worker_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: "Logfile": str(proc.get("stdout_logfile") or ""), "Uptime": str(str(proc.get("description") or "").split("uptime ", 1)[-1]), "Config": obj_to_yaml(relevant_config) if isinstance(relevant_config, dict) else str(relevant_config), - "Logs": relevant_logs, + "Recent Logs (last 100 KB)": relevant_logs, }, "help_texts": {"Uptime": "How long the process has been running ([days:]hours:minutes:seconds)"}, } @@ -528,9 +539,9 @@ def log_list_view(request: HttpRequest, **kwargs) -> TableContext: def log_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: assert is_superuser(request), "Must be a superuser to view configuration settings." - log_file = [logfile for logfile in CONSTANTS.LOGS_DIR.glob("*.log") if key in logfile.name][0] + log_file = next(logfile for logfile in CONSTANTS.LOGS_DIR.glob("*.log") if key in logfile.name) - log_text = log_file.read_text() + log_text = _read_text_tail(log_file) log_stat = log_file.stat() section: SectionData = { @@ -540,8 +551,8 @@ def log_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: "Path": str(log_file), "Size": f"{log_stat.st_size // 1000} kb", "Last Updated": format_parsed_datetime(log_stat.st_mtime), - "Tail": "\n".join(log_text[-10_000:].split("\n")[-20:]), - "Full Log": log_text, + "Tail": "\n".join(log_text.split("\n")[-20:]), + "Recent Log (last 100 KB)": log_text, }, } diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py index c4b153b6..2ebd004f 100644 --- a/archivebox/core/admin_archiveresults.py +++ b/archivebox/core/admin_archiveresults.py @@ -4,31 +4,29 @@ import html import json import os import shlex -from pathlib import Path -from urllib.parse import quote from functools import reduce from operator import and_ +from pathlib import Path +from urllib.parse import quote from django.contrib import admin +from django.core.exceptions import ValidationError from django.db.models import Min, Prefetch, Q, TextField from django.db.models.functions import Cast +from django.urls import resolve, reverse +from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe -from django.core.exceptions import ValidationError -from django.urls import reverse, resolve -from django.utils import timezone from django.utils.text import smart_split -from archivebox.misc.paginators import AcceleratedPaginator from archivebox.base_models.admin import BaseModelAdmin -from archivebox.plugins.discovery import get_plugin_icon -from archivebox.plugins.views import LIVE_PLUGIN_BASE_URL +from archivebox.core.models import ArchiveResult, Snapshot from archivebox.core.routes_util import build_snapshot_url from archivebox.core.widgets import InlineTagEditorWidget from archivebox.machine.env_util import env_to_shell_exports - - -from archivebox.core.models import ArchiveResult, Snapshot +from archivebox.misc.paginators import AcceleratedPaginator +from archivebox.plugins.discovery import get_plugin_icon +from archivebox.plugins.views import LIVE_PLUGIN_BASE_URL def _get_replay_source_url(result: ArchiveResult) -> str: @@ -120,15 +118,13 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None): status = result.status or "queued" color, bg = status_colors.get(status, ("#6b7280", "#f3f4f6")) output_files = result.output_files or {} - if isinstance(output_files, dict): - output_file_count = len(output_files) - elif isinstance(output_files, (list, tuple, set)): + if isinstance(output_files, (dict, list, tuple, set)): output_file_count = len(output_files) elif isinstance(output_files, str): try: parsed = json.loads(output_files) output_file_count = len(parsed) if isinstance(parsed, (dict, list, tuple, set)) else 0 - except Exception: + except (TypeError, ValueError): output_file_count = 0 else: output_file_count = 0 @@ -258,7 +254,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
- ID: {str(result.id)} + ID: {result.id!s} Version: {version} PWD: {pwd_text}
@@ -413,7 +409,6 @@ class ArchiveResultAdmin(BaseModelAdmin): list_display_links = None sort_fields = ("id", "created_at", "plugin", "status") readonly_fields = ( - "admin_actions", "cmd", "cmd_version", "pwd", @@ -437,16 +432,9 @@ class ArchiveResultAdmin(BaseModelAdmin): "output_json", "process__cmd", ) - autocomplete_fields = ["snapshot"] + autocomplete_fields = ("snapshot",) fieldsets = ( - ( - "Actions", - { - "fields": ("admin_actions",), - "classes": ("card", "wide"), - }, - ), ( "Snapshot", { @@ -485,14 +473,14 @@ class ArchiveResultAdmin(BaseModelAdmin): ) list_filter = ("status", "plugin", "start_ts") - ordering = ["-start_ts"] + ordering = ("-start_ts",) list_per_page = 50 paginator = AcceleratedPaginator save_on_top = True show_full_result_count = False - actions = ["delete_selected"] + actions = ("delete_selected",) class Meta: verbose_name = "Archive Result" @@ -502,6 +490,36 @@ class ArchiveResultAdmin(BaseModelAdmin): self.request = request return super().change_view(request, object_id, form_url, extra_context) + def get_admin_toolbar_actions(self, request, obj): + if obj is None: + return [] + self.request = request + return [ + { + "label": "View Output", + "icon": "📄", + "url": self.get_output_view_url(obj), + "title": "Open the archived output for this result", + }, + { + "label": "Output files", + "icon": "📁", + "url": self.get_output_files_url(obj), + "title": "Browse the output files for this result", + }, + { + "label": "Download Zip", + "icon": "⬇", + "url": self.get_output_zip_url(obj), + "kind": "accent", + "css_classes": "archivebox-zip-button", + "onclick": "return window.archiveboxHandleZipClick(this, event);", + "extra_attrs": [("data-loading-label", "Preparing...")], + "title": "Download all output files as a zip", + }, + {"label": "Snapshot", "icon": "🗂", "url": self.get_snapshot_view_url(obj), "title": "Open the parent snapshot view"}, + ] + def changelist_view(self, request, extra_context=None): self.request = request saved_list_per_page = self.list_per_page @@ -749,46 +767,6 @@ class ArchiveResultAdmin(BaseModelAdmin): output_text, ) - @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), - ) - def output_summary(self, result): snapshot_dir = Path(result.snapshot.output_dir) output_html = format_html( diff --git a/archivebox/core/admin_site.py b/archivebox/core/admin_site.py index 36ef51ba..e87c88a8 100644 --- a/archivebox/core/admin_site.py +++ b/archivebox/core/admin_site.py @@ -2,6 +2,15 @@ __package__ = "archivebox.core" from typing import TYPE_CHECKING, Any +from admin_data_views.admin import ( + admin_data_index_view as adv_admin_data_index_view, +) +from admin_data_views.admin import ( + get_admin_data_urls as adv_get_admin_data_urls, +) +from admin_data_views.admin import ( + get_app_list as adv_get_app_list, +) from django.contrib import admin from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_not_required @@ -12,23 +21,17 @@ from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.decorators.cache import never_cache -from admin_data_views.admin import ( - admin_data_index_view as adv_admin_data_index_view, - get_admin_data_urls as adv_get_admin_data_urls, - get_app_list as adv_get_app_list, -) from archivebox.config import VERSION -from archivebox.config.version import get_COMMIT_HASH +from archivebox.core.context_processors import get_static_cache_key from archivebox.core.routes_util import is_allowed_archivebox_redirect_url if TYPE_CHECKING: + from admin_data_views.typing import AppDict from django.http import HttpRequest from django.template.response import TemplateResponse from django.urls import URLPattern, URLResolver - from admin_data_views.typing import AppDict - class ArchiveBoxLoginView(LoginView): def get_redirect_url(self) -> str: @@ -50,7 +53,7 @@ class ArchiveBoxAdmin(admin.AdminSite): def each_context(self, request: "HttpRequest") -> dict[str, Any]: context = super().each_context(request) context["VERSION"] = VERSION - context["STATIC_CACHE_KEY"] = (get_COMMIT_HASH() or VERSION or "dev").strip() + context["STATIC_CACHE_KEY"] = get_static_cache_key() return context @staticmethod @@ -183,9 +186,9 @@ def register_admin_site(): # Register admin views for each app # (Previously handled by ABX plugin system, now called directly) + from archivebox.api.admin import register_admin as register_api_admin from archivebox.core.admin import register_admin as register_core_admin from archivebox.crawls.admin import register_admin as register_crawls_admin - from archivebox.api.admin import register_admin as register_api_admin from archivebox.machine.admin import register_admin as register_machine_admin from archivebox.personas.admin import register_admin as register_personas_admin from archivebox.workers.admin import register_admin as register_workers_admin diff --git a/archivebox/core/context_processors.py b/archivebox/core/context_processors.py index 623265e5..09b55099 100644 --- a/archivebox/core/context_processors.py +++ b/archivebox/core/context_processors.py @@ -1,9 +1,21 @@ +from pathlib import Path + from archivebox.config import VERSION from archivebox.config.version import get_COMMIT_HASH +def get_static_cache_key() -> str: + """Version the admin stylesheet even when the checkout has uncommitted edits.""" + base_key = (get_COMMIT_HASH() or VERSION or "dev").strip() + admin_css_path = Path(__file__).resolve().parent.parent / "templates" / "static" / "admin.css" + try: + return f"{base_key}-{admin_css_path.stat().st_mtime_ns}" + except OSError: + return base_key + + def archivebox_globals(request): return { "VERSION": VERSION, - "STATIC_CACHE_KEY": (get_COMMIT_HASH() or VERSION or "dev").strip(), + "STATIC_CACHE_KEY": get_static_cache_key(), } diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py index 94f973e6..45f8ab5f 100644 --- a/archivebox/core/forms.py +++ b/archivebox/core/forms.py @@ -1,16 +1,17 @@ __package__ = "archivebox.core" import re -from decimal import Decimal, InvalidOperation, ROUND_CEILING +from decimal import ROUND_CEILING, Decimal, InvalidOperation from django import forms -from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes from archivebox.base_models.admin import KeyValueWidget -from archivebox.crawls.schedule_util import validate_schedule from archivebox.config.common import get_config, parse_delete_after from archivebox.core.permissions import PERMISSIONS_CHOICES, PERMISSIONS_PUBLIC, filter_personas_by_permissions from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget +from archivebox.crawls.schedule_util import validate_schedule +from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes +from archivebox.personas.models import Persona from archivebox.plugins.discovery import get_plugins from archivebox.plugins.forms import ( PLUGIN_GROUP_DEFINITIONS, @@ -18,7 +19,6 @@ from archivebox.plugins.forms import ( PluginConfigFormMixin, get_choice_field, ) -from archivebox.personas.models import Persona DEPTH_CHOICES = ( ("0", "depth = 0 (archive just these URLs)"), @@ -101,12 +101,11 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form): attrs={ "data-url-regex": URL_REGEX.pattern, "placeholder": ( - "\n", - "Enter URL(s) to archive. Any format is ok: one per line, CSV, JSON, embedded in text, etc." + "Enter URL(s) to archive. Any format is ok: one per line, CSV, JSON, embedded in text, etc.\n\n" "Examples:\n\n" "https://example.com\n\n" "https://news.ycombinator.com,https://news.google.com\n\n" - "Or any text-based content [containing URLs](https://github.com/ArchiveBox/ArchiveBox)...", + "Or any text-based content [containing URLs](https://github.com/ArchiveBox/ArchiveBox)..." ), }, ), diff --git a/archivebox/core/models.py b/archivebox/core/models.py old mode 100755 new mode 100644 index c710574a..1dd17944 --- a/archivebox/core/models.py +++ b/archivebox/core/models.py @@ -1,68 +1,72 @@ __package__ = "archivebox.core" -from typing import TYPE_CHECKING, Optional, Any -from collections.abc import Iterable, Mapping, Sequence -import uuid -from archivebox.uuid_compat import CompactUUIDField, uuid7 -from datetime import datetime, timedelta - -import os import json +import os +import uuid +from collections.abc import Iterable, Mapping, Sequence +from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, Optional from urllib.parse import urlparse -from statemachine import State, registry - +from django.conf import settings +from django.contrib import admin +from django.core.cache import cache +from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import models, transaction from django.db.models import Case, F, Q, QuerySet, Sum, Value, When -from django.db.models.functions import Coalesce, Concat from django.db.models.fields.json import KT -from django.utils.functional import cached_property -from django.utils.text import slugify -from django.utils import timezone -from django.core.cache import cache +from django.db.models.functions import Coalesce, Concat from django.urls import reverse_lazy -from django.contrib import admin -from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.utils import timezone +from django.utils.functional import cached_property from django.utils.safestring import mark_safe +from django.utils.text import slugify +from statemachine import State, registry +from archivebox.base_models.models import ( + ModelWithConfig, + ModelWithDeleteAfter, + ModelWithHealthStats, + ModelWithNotes, + ModelWithOutputDir, + ModelWithUUID, + get_or_create_system_user_pk, +) from archivebox.config import CONSTANTS from archivebox.config.common import get_config, rprint +from archivebox.crawls.models import Crawl +from archivebox.machine.models import Binary from archivebox.misc.system import atomic_write from archivebox.misc.util import ( - parse_date, domain as url_domain, +) +from archivebox.misc.util import ( + htmldecode, + parse_date, + sanitize_html_text, to_json, ts_to_date_str, - urlencode, - htmldecode, - sanitize_html_text, urldecode, + urlencode, validate_url, ) from archivebox.plugins.discovery import ( - get_plugins, - get_plugin_name, get_plugin_icon, + get_plugin_name, + get_plugins, ) -from archivebox.base_models.models import ( - ModelWithUUID, - ModelWithDeleteAfter, - ModelWithOutputDir, - ModelWithConfig, - ModelWithNotes, - ModelWithHealthStats, - get_or_create_system_user_pk, -) -from archivebox.workers.models import ACTIVE_STATE_LEASE_SECONDS, RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine -from archivebox.crawls.models import Crawl -from archivebox.machine.models import Binary +from archivebox.uuid_compat import CompactUUIDField, uuid7 +from archivebox.workers.models import ACTIVE_STATE_LEASE_SECONDS, RETRY_AT_MAX, BaseStateMachine, ModelWithStateMachine if TYPE_CHECKING: from archivebox.config.common import ArchiveBoxBaseConfig +class SnapshotMigrationError(RuntimeError): + """Raised when a snapshot filesystem migration fails validation.""" + + class UngroupedSubquery(models.Subquery): """Scalar subquery that should not be copied into the outer GROUP BY.""" @@ -156,7 +160,7 @@ class SnapshotTag(models.Model): class Meta: app_label = "core" db_table = "core_snapshot_tags" - unique_together = [("snapshot", "tag")] + unique_together: ClassVar[list[tuple[str, str]]] = [("snapshot", "tag")] class SnapshotQuerySet(models.QuerySet): @@ -226,7 +230,7 @@ class SnapshotQuerySet(models.QuerySet): field_name = pk_field ordering.append(f"-{field_name}" if descending else field_name) - ordered_field_names = [term[1:] if term.startswith("-") else term for term in ordering] + ordered_field_names = [term.removeprefix("-") for term in ordering] try: if any(self.model._meta.get_field(field_name).null for field_name in ordered_field_names): offset = 0 @@ -237,7 +241,7 @@ class SnapshotQuerySet(models.QuerySet): yield from batch offset += chunk_size return - except Exception: + except (AttributeError, FieldDoesNotExist): offset = 0 while True: batch = list(self[offset : offset + chunk_size]) @@ -292,7 +296,7 @@ class SnapshotQuerySet(models.QuerySet): # Filtering Methods # ========================================================================= - FILTER_TYPES = { + FILTER_TYPES: ClassVar[dict[str, Any]] = { "exact": lambda pattern: models.Q(url=pattern), "substring": lambda pattern: models.Q(url__icontains=pattern), "regex": lambda pattern: models.Q(url__iregex=pattern), @@ -337,7 +341,6 @@ class SnapshotQuerySet(models.QuerySet): return self.filter(q_filter) def search(self, **kwargs) -> "SnapshotQuerySet": - from datetime import timezone as dt_timezone from archivebox.core.snapshot_status import filter_snapshots_by_status from archivebox.search.query import apply_snapshot_search @@ -364,9 +367,9 @@ class SnapshotQuerySet(models.QuerySet): if kwargs.get("tag"): queryset = queryset.filter(tags__name__iexact=kwargs["tag"]) if kwargs.get("before") is not None: - queryset = queryset.filter(bookmarked_at__lt=datetime.fromtimestamp(float(kwargs["before"]), tz=dt_timezone.utc)) + queryset = queryset.filter(bookmarked_at__lt=datetime.fromtimestamp(float(kwargs["before"]), tz=UTC)) if kwargs.get("after") is not None: - queryset = queryset.filter(bookmarked_at__gt=datetime.fromtimestamp(float(kwargs["after"]), tz=dt_timezone.utc)) + queryset = queryset.filter(bookmarked_at__gt=datetime.fromtimestamp(float(kwargs["after"]), tz=UTC)) if query: queryset = apply_snapshot_search( @@ -399,7 +402,8 @@ class SnapshotQuerySet(models.QuerySet): def to_json(self, with_headers: bool = False) -> str: """Generate JSON index from snapshots""" import sys - from datetime import datetime, timezone as tz + from datetime import datetime + from archivebox.config import VERSION config = get_config() @@ -430,7 +434,7 @@ class SnapshotQuerySet(models.QuerySet): output = { **MAIN_INDEX_HEADER, "num_links": len(snapshot_dicts), - "updated": datetime.now(tz.utc), + "updated": datetime.now(UTC), "last_run_cmd": sys.argv, "links": snapshot_dicts, } @@ -447,8 +451,10 @@ class SnapshotQuerySet(models.QuerySet): def to_html(self, with_headers: bool = True) -> str: """Generate main index HTML from snapshots""" - from datetime import datetime, timezone as tz + from datetime import datetime + from django.template.loader import render_to_string + from archivebox.config import VERSION from archivebox.config.version import get_COMMIT_HASH @@ -463,8 +469,8 @@ class SnapshotQuerySet(models.QuerySet): "version": VERSION, "git_sha": get_COMMIT_HASH() or VERSION, "num_links": str(len(snapshot_list)), - "date_updated": datetime.now(tz.utc).strftime("%Y-%m-%d"), - "time_updated": datetime.now(tz.utc).strftime("%Y-%m-%d %H:%M"), + "date_updated": datetime.now(UTC).strftime("%Y-%m-%d"), + "time_updated": datetime.now(UTC).strftime("%Y-%m-%d %H:%M"), "links": snapshot_list, "FOOTER_INFO": config.FOOTER_INFO, }, @@ -603,11 +609,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW app_label = "core" verbose_name = "Snapshot" verbose_name_plural = "Snapshots" - indexes = [ + indexes: ClassVar[list[models.Index]] = [ models.Index(fields=["-bookmarked_at", "-created_at"], name="snapshot_public_order_idx"), models.Index(fields=["crawl", "status", "modified_at"], name="snapshot_progress_idx"), ] - constraints = [ + constraints: ClassVar[list[models.BaseConstraint]] = [ # Allow same URL in different crawls, but not duplicates within same crawl models.UniqueConstraint(fields=["url", "crawl"], name="unique_url_per_crawl"), # Global timestamp uniqueness for 1:1 symlink mapping @@ -899,9 +905,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW from archivebox.core.permissions import PERMISSIONS_PUBLIC, PERMISSIONS_VALUES, normalize_permissions if permission not in PERMISSIONS_VALUES: - if self.crawl_id: - if not crawl_permissions: - crawl_permissions = Crawl.objects.filter(pk=self.crawl_id).values_list("permissions", flat=True).first() + if self.crawl_id and not crawl_permissions: + crawl_permissions = Crawl.objects.filter(pk=self.crawl_id).values_list("permissions", flat=True).first() config["PERMISSIONS"] = normalize_permissions( crawl_permissions, default=PERMISSIONS_PUBLIC, @@ -940,9 +945,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW crawl_config_for_save = crawl_row.get("config") or {} crawl_permissions_for_save = crawl_row.get("permissions") - if self.ensure_permissions_config(crawl_permissions=crawl_permissions_for_save): - if update_fields is not None: - kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"])) + if self.ensure_permissions_config(crawl_permissions=crawl_permissions_for_save) and update_fields is not None: + kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"])) if validate_url_field: self.validate_url_for_archiving(config=crawl_config_for_save if self.crawl_id else None) @@ -1191,9 +1195,16 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW new_file = new_dir / rel_path # Skip if already copied - if new_file.exists(): - if new_file.stat().st_size == old_file.stat().st_size and filecmp.cmp(old_file, new_file, shallow=False): - continue + if ( + new_file.exists() + and new_file.stat().st_size == old_file.stat().st_size + and filecmp.cmp( + old_file, + new_file, + shallow=False, + ) + ): + continue new_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(old_file, new_file) @@ -1206,15 +1217,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW missing = old_files.keys() - new_files.keys() missing.discard(Path(CONSTANTS.JSON_INDEX_FILENAME)) if missing: - raise Exception(f"Migration incomplete: missing {missing}") + raise SnapshotMigrationError(f"Migration incomplete: missing {missing}") for rel_path, old_size in old_files.items(): if rel_path == Path(CONSTANTS.JSON_INDEX_FILENAME): continue if new_files.get(rel_path) != old_size: - raise Exception(f"Migration incomplete: size mismatch for {rel_path}") + raise SnapshotMigrationError(f"Migration incomplete: size mismatch for {rel_path}") if not filecmp.cmp(old_dir / rel_path, new_dir / rel_path, shallow=False): - raise Exception(f"Migration incomplete: content mismatch for {rel_path}") + raise SnapshotMigrationError(f"Migration incomplete: content mismatch for {rel_path}") # Convert index.json to index.jsonl in the new directory. self.convert_index_json_to_jsonl(output_dir=new_dir) @@ -1225,14 +1236,14 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW """ Delete old directory and create symlink after successful migration. """ - import shutil import logging + import shutil # Delete old directory if old_dir.exists() and not old_dir.is_symlink(): try: shutil.rmtree(old_dir) - except Exception as e: + except OSError as e: logging.getLogger("archivebox.migration").warning( f"Could not remove old migration directory {old_dir}: {e}", ) @@ -1246,7 +1257,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if not symlink_path.exists(): try: symlink_path.symlink_to(new_dir, target_is_directory=True) - except Exception as e: + except OSError as e: logging.getLogger("archivebox.migration").warning( f"Could not create symlink from {symlink_path} to {new_dir}: {e}", ) @@ -1282,7 +1293,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW return parsed.scheme else: return "unknown" - except Exception: + except (TypeError, ValueError): return "unknown" def get_storage_path_for_version(self, version: str) -> Path: @@ -1722,6 +1733,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW """Create ArchiveResult if not already in DB.""" from dateutil import parser from django.db import transaction + from archivebox.machine.models import Machine, Process # Support both old 'extractor' and new 'plugin' keys for backwards compat @@ -1909,11 +1921,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW """ from archivebox.machine.models import Process from archivebox.misc.jsonl import ( - TYPE_SNAPSHOT, TYPE_ARCHIVERESULT, - TYPE_BINARYREQUEST, TYPE_BINARY, + TYPE_BINARYREQUEST, TYPE_PROCESS, + TYPE_SNAPSHOT, ) output_dir = Path(output_dir) if output_dir is not None else Path(self.output_dir) @@ -2059,10 +2071,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW Used by: archivebox update (when encountering invalid directories) """ - from datetime import datetime import shutil - invalid_dir = CONSTANTS.DATA_DIR / "invalid" / datetime.now().strftime("%Y%m%d") + invalid_dir = CONSTANTS.DATA_DIR / "invalid" / datetime.now(UTC).strftime("%Y%m%d") invalid_dir.mkdir(parents=True, exist_ok=True) dest = invalid_dir / snapshot_dir.name @@ -2073,8 +2084,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW try: shutil.move(str(snapshot_dir), str(dest)) - except Exception: - pass + except OSError: + return @classmethod def find_and_merge_duplicates(cls) -> int: @@ -2098,8 +2109,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW try: cls._merge_snapshots(snapshots) merged += 1 - except Exception: - pass + except OSError: + continue return merged @@ -2134,8 +2145,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW try: shutil.rmtree(dup_dir) - except Exception: - pass + except OSError: + continue # Merge tags for tag in dup.tags.all(): @@ -2200,6 +2211,29 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW running = int(progress_stats.get("running") or 0) completed = succeeded + failed + skipped + noresults percent = int((completed / total * 100) if total > 0 else 0) + successful_plugins = sorted(self.__dict__.get("_icons_archive_results") or ()) + visible_plugins = successful_plugins[:8] + successful_icons = format_html( + '
{}
', + mark_safe( + "".join( + str(format_html('{}', plugin, mark_safe(get_plugin_icon(plugin)))) + for plugin in visible_plugins + if str(get_plugin_icon(plugin)).strip() + ) + + ( + str( + format_html( + '+{}', + len(successful_plugins) - 8, + len(successful_plugins) - 8, + ), + ) + if len(successful_plugins) > 8 + else "" + ), + ), + ) return format_html( '
' '
' @@ -2212,6 +2246,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW '
' "✓{} ✗{} ⏳{}" "
" + "{}" "
", completed, total, @@ -2221,6 +2256,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW succeeded, failed, running, + successful_icons, ) precomputed_archive_results = self.__dict__.get("_icons_archive_results") @@ -2363,7 +2399,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW try: data = json.loads(hashes_path.read_text(encoding="utf-8")) - except Exception: + except (json.JSONDecodeError, OSError, TypeError, ValueError): return {} index: dict[str, dict[str, Any]] = {} @@ -2524,7 +2560,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW output_dir = Path(self.output_dir).resolve() try: rel_users_path = output_dir.relative_to(CONSTANTS.USERS_DIR) - except Exception: + except ValueError: rel_users_path = None if rel_users_path: @@ -2541,7 +2577,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW try: rel_path = output_dir.relative_to(CONSTANTS.DATA_DIR) - except Exception: + except ValueError: return self.legacy_archive_path parts = rel_path.parts @@ -2687,7 +2723,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW Snapshot instance or None """ import re + from django.utils import timezone + from archivebox.base_models.models import get_or_create_system_user_pk config = get_config() @@ -2712,9 +2750,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW continue # Special parsing for date fields - if field_name in ("bookmarked_at", "retry_at", "created_at", "modified_at"): - if value and isinstance(value, str): - value = parse_date(value) + if field_name in ("bookmarked_at", "retry_at", "created_at", "modified_at") and value and isinstance(value, str): + value = parse_date(value) # Update field if value is provided and different if value is not None and getattr(snapshot, field_name) != value: @@ -2758,8 +2795,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW crawl = parent_snapshot.crawl else: # Auto-create a single-URL crawl - from archivebox.crawls.models import Crawl from archivebox.config import CONSTANTS + from archivebox.crawls.models import Crawl timestamp_str = timezone.now().strftime("%Y-%m-%d__%H-%M-%S") sources_file = CONSTANTS.SOURCES_DIR / f"{timestamp_str}__auto_crawl.txt" @@ -2876,8 +2913,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW return [] if hooks is None: - from archivebox.plugins.hooks import discover_hooks from archivebox.config.common import get_config + from archivebox.plugins.hooks import discover_hooks # Compatibility path for direct model callers. The runner passes its # abx-dl hook inventory explicitly so queued rows match execution. @@ -3127,9 +3164,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW def latest_outputs(self, status: str | None = None) -> dict[str, Any]: """Get the latest output that each plugin produced""" - from archivebox.plugins.discovery import get_plugins from django.db.models import Q + from archivebox.plugins.discovery import get_plugins + latest: dict[str, Any] = {} for plugin in get_plugins(): results = self.archiveresult_set.filter(plugin=plugin) @@ -3388,6 +3426,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW def write_html_details(self, out_dir: Path | str | None = None) -> None: """Write HTML detail page for this snapshot to its output directory""" from django.template.loader import render_to_string + from archivebox.core.widgets import TagEditorWidget from archivebox.misc.logging_util import printable_filesize @@ -3427,7 +3466,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW "snapshot": self, "title": htmldecode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)), "url_str": htmldecode(urldecode(self.base_url)), - "archive_url": urlencode(f"warc/{self.timestamp}" or (self.domain if is_archived else "")) or "about:blank", + "archive_url": urlencode(f"warc/{self.timestamp}") or "about:blank", "extension": self.extension or "html", "tags": self.tags_str() or "untagged", "size": printable_filesize(output_size) if output_size else "pending", @@ -3861,12 +3900,12 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes): app_label = "core" verbose_name = "Archive Result" verbose_name_plural = "Archive Results Log" - indexes = [ + indexes: ClassVar[list[models.Index]] = [ models.Index(fields=["snapshot", "status"], name="archiveresult_snap_status_idx"), models.Index(fields=["status", "snapshot"], name="archiveresult_status_snap_idx"), models.Index(fields=["-start_ts", "-id"], name="archiveresult_start_idx"), ] - constraints = [ + constraints: ClassVar[list[models.BaseConstraint]] = [ models.UniqueConstraint(fields=["snapshot", "plugin", "hook_name"], name="unique_archiveresult_per_snapshot_hook"), ] @@ -4228,6 +4267,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes): def update_output_metadata_from_filesystem(self, snapshot_dir: Path | None = None, save: bool = True) -> bool: from collections import defaultdict + from abx_dl.output_files import guess_mimetype if self.plugin == "title": @@ -4582,10 +4622,12 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes): """ from collections import defaultdict from pathlib import Path - from django.utils import timezone + from abx_dl.output_files import guess_mimetype - from archivebox.plugins.hooks import process_hook_records, extract_records_from_process + from django.utils import timezone + from archivebox.machine.models import Process + from archivebox.plugins.hooks import extract_records_from_process, process_hook_records plugin_dir = Path(self.pwd) if self.pwd else None if not plugin_dir or not plugin_dir.exists(): @@ -4637,8 +4679,8 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes): from archivebox.plugins.hooks import is_background_hook is_background = bool(self.hook_name and is_background_hook(self.hook_name)) - except Exception: - pass + except (ImportError, TypeError, ValueError): + is_background = False if is_background or (process and process.exit_code == 0): self.status = self.StatusChoices.SKIPPED diff --git a/archivebox/core/routes_util.py b/archivebox/core/routes_util.py index b47f6a26..60278480 100644 --- a/archivebox/core/routes_util.py +++ b/archivebox/core/routes_util.py @@ -6,8 +6,7 @@ from urllib.parse import urlparse from django.utils.http import url_has_allowed_host_and_scheme -from archivebox.config.common import get_config - +from archivebox.config.common import get_config, get_request_config _SNAPSHOT_ID_RE = re.compile(r"^[0-9a-fA-F-]{8,36}$") _SNAPSHOT_SUBDOMAIN_RE = re.compile(r"^snap-(?P[0-9a-fA-F]{12})$") @@ -153,7 +152,7 @@ def _root_host_from_listen(config: dict[str, Any] | None = None, **config_kwargs def get_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str: - config = config or get_config(**config_kwargs) + config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs)) override = _normalize_base_url(config.BASE_URL) if override: return override @@ -281,9 +280,7 @@ def host_matches(request_host: str, target_host: str) -> bool: target_host_only, target_port = split_host_port(target_host) if req_host != target_host_only: return False - if target_port and req_port and target_port != req_port: - return False - return True + return not (target_port and req_port and target_port != req_port) def is_allowed_archivebox_redirect_url(url: str | None, request=None, config: dict[str, Any] | None = None) -> bool: @@ -352,28 +349,28 @@ def _build_base_url_for_host(host: str, request=None, config: dict[str, Any] | N def get_admin_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str: - config = config or get_config(**config_kwargs) + config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs)) if not config.USES_SUBDOMAIN_ROUTING: return get_base_url(request=request, config=config) return _build_base_url_for_host(_build_base_host("admin", request=request, config=config), request=request, config=config) def get_web_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str: - config = config or get_config(**config_kwargs) + config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs)) if not config.USES_SUBDOMAIN_ROUTING: return get_base_url(request=request, config=config) return _build_base_url_for_host(_build_base_host("web", request=request, config=config), request=request, config=config) def get_api_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str: - config = config or get_config(**config_kwargs) + config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs)) if not config.USES_SUBDOMAIN_ROUTING: return get_base_url(request=request, config=config) return _build_base_url_for_host(_build_base_host("api", request=request, config=config), request=request, config=config) def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str: - config = config or get_config(**config_kwargs) + config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs)) if not config.USES_SUBDOMAIN_ROUTING: return _build_url(get_web_base_url(request=request, config=config), f"/snapshot/{str(snapshot_id).replace('-', '')}") return _build_base_url_for_host( @@ -384,7 +381,7 @@ def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any] def get_original_base_url(domain: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str: - config = config or get_config(**config_kwargs) + config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs)) if not config.USES_SUBDOMAIN_ROUTING: return _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}") return _build_base_url_for_host(_build_base_host(domain, request=request, config=config), request=request, config=config) diff --git a/archivebox/core/setup_wizard.py b/archivebox/core/setup_wizard.py new file mode 100644 index 00000000..36020c33 --- /dev/null +++ b/archivebox/core/setup_wizard.py @@ -0,0 +1,68 @@ +from typing import Any + +from archivebox.core.routes_util import canonical_base_host_for_request, get_base_url, split_host_port + + +def get_setup_wizard_context(request, config) -> dict[str, Any]: + """Build the first-run setup context when ``BASE_URL`` is unset.""" + context = { + "mode": "unconfigured", + "canonical_host": "", + "display_host": "", + "suggested_base_url": "", + "machine_admin_url": "", + "can_configure": False, + "public_index": config.PUBLIC_INDEX, + "public_add_view": config.PUBLIC_ADD_VIEW, + "permissions": config.PERMISSIONS, + } + if request is None: + return context + + scheme = request.scheme or "http" + canonical_host = canonical_base_host_for_request(request.get_host() or "") + display_hostname, display_port = split_host_port(canonical_host) + display_host = canonical_host + if (scheme, display_port) in (("http", "80"), ("https", "443")): + display_host = display_hostname + + user = request.user + is_superuser = bool(user and user.is_authenticated and user.is_superuser) + machine_admin_url = "" + if is_superuser: + try: + from archivebox.machine.models import Machine + + machine_admin_url = f"/admin/machine/machine/{Machine.current().id}/change/" + except (ImportError, RuntimeError, TypeError, ValueError): + machine_admin_url = "" + + context.update( + canonical_host=canonical_host, + display_host=display_host, + suggested_base_url=f"{scheme}://{canonical_host}" if canonical_host else "", + machine_admin_url=machine_admin_url, + can_configure=is_superuser, + ) + return context + + +def get_base_url_mismatch_context(request, config) -> dict[str, str] | None: + """Describe a request origin that does not resolve to the configured base.""" + if request is None or not config.BASE_URL: + return None + + scheme = request.scheme or "http" + browser_url = f"{scheme}://{request.get_host()}".rstrip("/") + browser_base_url = browser_url + if config.USES_SUBDOMAIN_ROUTING: + browser_base_url = f"{scheme}://{canonical_base_host_for_request(request.get_host())}".rstrip("/") + configured_base_url = get_base_url(config=config).rstrip("/") + if browser_base_url.lower() == configured_base_url.lower(): + return None + + return { + "mode": "base_url_mismatch", + "browser_url": browser_url, + "configured_base_url": configured_base_url, + } diff --git a/archivebox/core/templatetags/core_tags.py b/archivebox/core/templatetags/core_tags.py index 11825af8..30ecd6ee 100644 --- a/archivebox/core/templatetags/core_tags.py +++ b/archivebox/core/templatetags/core_tags.py @@ -1,36 +1,33 @@ +import os +from pathlib import Path from typing import Any +from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target from django import template from django.contrib.admin.templatetags.base import InclusionAdminNode -from django.utils.safestring import mark_safe -from django.utils.html import escape from django.templatetags.static import static from django.utils import timezone +from django.utils.html import escape +from django.utils.safestring import mark_safe from django.utils.text import Truncator -from pathlib import Path - -from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target - +from archivebox.core.routes_util import ( + build_snapshot_url, + get_admin_base_url, + get_snapshot_base_url, + get_web_base_url, +) +from archivebox.core.setup_wizard import get_base_url_mismatch_context, get_setup_wizard_context from archivebox.plugins.discovery import ( get_plugin_icon, - get_plugin_template, get_plugin_name, + get_plugin_template, ) -from archivebox.core.routes_util import ( - canonical_base_host_for_request, - get_admin_base_url, - get_web_base_url, - get_snapshot_base_url, - build_snapshot_url, -) - register = template.Library() _TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml", ".yaml", ".md", ".log") _IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif") -_MHTML_PREVIEW_EXTS = (".mhtml", ".mht") _MEDIA_FILE_EXTS = { ".mp4", @@ -94,15 +91,15 @@ def _coerce_output_file_size(value: Any) -> int | None: def _count_media_files(result) -> int: try: output_files = _normalize_output_files(result.output_files or {}) - except Exception: + except (AttributeError, TypeError, ValueError): output_files = {} if output_files: - return sum(1 for path in output_files.keys() if Path(path).suffix.lower() in _MEDIA_FILE_EXTS) + return sum(1 for path in output_files if Path(path).suffix.lower() in _MEDIA_FILE_EXTS) try: plugin_dir = Path(result.snapshot_dir) / result.plugin - except Exception: + except (AttributeError, TypeError, ValueError): return 0 if not plugin_dir.exists(): @@ -126,7 +123,7 @@ def _list_media_files(result) -> list[dict]: media_files: list[dict] = [] try: plugin_dir = Path(result.snapshot_dir) / result.plugin - except Exception: + except (AttributeError, TypeError, ValueError): return media_files output_files = _normalize_output_files(result.output_files or {}) @@ -184,7 +181,7 @@ def _resolve_snapshot_output_file(snapshot_dir: str | Path | None, raw_output_pa snap_dir = Path(snapshot_dir).resolve() if snap_dir not in output_file.parents and output_file != snap_dir: return None - except Exception: + except (OSError, RuntimeError, TypeError, ValueError): return None if output_file.exists() and output_file.is_file(): @@ -209,17 +206,17 @@ def _build_snapshot_files_url(snapshot_id: str, request=None, config=None) -> st return build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config) -def _build_snapshot_preview_url(snapshot_id: str, path: str = "", request=None, config=None) -> str: +def _build_snapshot_preview_url(snapshot_id: str, path: str = "", request=None, config=None, plugin: str = "") -> str: if path == "about:blank": return path if _is_root_snapshot_output_path(path): return _build_snapshot_files_url(snapshot_id, request=request, config=config) url = build_snapshot_url(str(snapshot_id), path, request=request, config=config) + path_parts = Path(path).parts + plugin = get_plugin_name(plugin) if plugin else (path_parts[0] if len(path_parts) > 1 else "") + has_plugin_preview = bool(plugin and get_plugin_template(plugin, "full", fallback=False)) if not ( - _is_text_preview_path(path) - or _is_image_preview_path(path) - or (path or "").lower().endswith(_MHTML_PREVIEW_EXTS) - or is_archivewebpage_replay_target(path or "") + _is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview or is_archivewebpage_replay_target(path or "") ): return url separator = "&" if "?" in url else "?" @@ -268,7 +265,7 @@ def _render_text_file_preview(snapshot_dir: str | Path | None, raw_output_path: lines = text.splitlines()[:6] snippet = "\n".join(lines) return _render_text_preview(plugin, icon_html, snippet) - except Exception: + except (OSError, UnicodeDecodeError, ValueError): return None @@ -281,12 +278,12 @@ def split(value, separator: str = ","): def index(value, position): try: return value[int(position)] - except Exception: + except (IndexError, TypeError, ValueError): return None @register.filter -def file_size(num_bytes: int | float) -> str: +def file_size(num_bytes: float) -> str: for count in ["Bytes", "KB", "MB", "GB"]: if num_bytes > -1024.0 and num_bytes < 1024.0: return f"{num_bytes:3.1f} {count}" @@ -359,7 +356,7 @@ def _machine_health_stats() -> dict: from archivebox.machine.detect import get_host_stats stats = get_host_stats() or {} - except Exception: + except (ImportError, RuntimeError, TypeError, ValueError): stats = {} _health_cache["checked_at"] = now @@ -375,13 +372,15 @@ def system_warnings_banner(context): 1. ``mode="unconfigured"``— ``BASE_URL`` is empty. Security/correctness issue: until it's pinned, generated URLs can echo any Host the client sends, and admin/web/api routing has no canonical anchor. - 2. ``mode="unsafe"`` — ``SERVER_SECURITY_MODE`` is a non-subdomain - mode. Archived pages share an origin with privileged routes. - 3. ``mode="low_disk"`` — ``DATA_DIR`` has <1 GiB free; new archive + 2. ``mode="base_url_mismatch"`` — the browser reached ArchiveBox through + an origin that differs from the configured canonical ``BASE_URL``. + 3. ``mode="unsafe"`` — ``SERVER_SECURITY_MODE`` explicitly enables + a lower-security replay mode. + 4. ``mode="low_disk"`` — ``DATA_DIR`` has <1 GiB free; new archive jobs will start failing on ENOSPC. - 4. ``mode="high_memory"`` — virtual memory utilization at/above 95%; the + 5. ``mode="high_memory"`` — virtual memory utilization at/above 95%; the host is one OOM-kill from a crash. - 5. ``mode="high_load"`` — 15-minute load average exceeds 3 × CPU count + 6. ``mode="high_load"`` — 15-minute load average exceeds 3 × CPU count (the kernel's own sustained-load EMA, so no rolling buffer of ours is needed). @@ -397,8 +396,11 @@ def system_warnings_banner(context): config = get_config(resolve_plugins=False) if not config.BASE_URL: - return _unconfigured_banner_context(context.get("request")) - if not config.USES_SUBDOMAIN_ROUTING: + return get_setup_wizard_context(context.get("request"), config) + mismatch = get_base_url_mismatch_context(context.get("request"), config) + if mismatch: + return mismatch + if config.IS_LOWER_SECURITY_MODE: return {"mode": "unsafe"} stats = _machine_health_stats() @@ -417,6 +419,8 @@ def system_warnings_banner(context): # "sustained for 15min" and the kernel already maintains that EMA. load_15 = cpu_load[2] if isinstance(cpu_load, (list, tuple)) and len(cpu_load) >= 3 else None if isinstance(load_15, (int, float)) and load_15 > _HIGH_LOAD_MULTIPLE * cpu_count: + if os.environ.get("UI_SCREENSHOT_HIDE_HIGH_LOAD_WARNING") == "1": + return {"mode": ""} return { "mode": "high_load", "load_15": f"{load_15:.2f}", @@ -427,49 +431,6 @@ def system_warnings_banner(context): return {"mode": ""} -def _unconfigured_banner_context(request) -> dict: - """Build the banner payload for the unset-BASE_URL case. - - Always returns ``mode="unconfigured"`` — the user explicitly asked for - the banner to render whenever ``BASE_URL`` is empty, regardless of - whether the request host happens to match a CSRF-derived value. The - ``suggested_base_url`` is derived from the current request when one is - available so the user can copy/paste the right value straight into - their config. - """ - if request is None: - return { - "mode": "unconfigured", - "actual_host": "", - "suggested_base_url": "", - "machine_admin_url": "", - } - scheme = request.scheme or "http" - actual_full_host = request.get_host() or "" - canonical_host = canonical_base_host_for_request(actual_full_host) - # Suggest the wildcard form ``http://*.`` so the value lands in the - # operator's clipboard already aligned with subdomain routing. The config - # parser strips the leading ``*.`` so users can paste it verbatim. - suggested_base_url = f"{scheme}://*.{canonical_host}" if canonical_host else "" - user = request.user - is_superuser = bool(user and user.is_authenticated and user.is_superuser) - machine_admin_url = "" - if is_superuser: - try: - from archivebox.machine.models import Machine - - machine = Machine.current() - machine_admin_url = f"/admin/machine/machine/{machine.id}/change/" - except Exception: - machine_admin_url = "" - return { - "mode": "unconfigured", - "actual_host": actual_full_host, - "suggested_base_url": suggested_base_url, - "machine_admin_url": machine_admin_url, - } - - @register.simple_tag(takes_context=True) def url_replace(context, **kwargs): dict_ = context["request"].GET.copy() @@ -581,7 +542,7 @@ def snapshot_index_row(context, link) -> str: url = getattr(link, "url", "") or "" title = getattr(link, "title", "") or "" is_pending = status in {"queued", "started", "backoff"} - title_text = title or ("Loading..." if is_pending else url) + title_text = title or url tags_str = link.tags_str() if callable(getattr(link, "tags_str", None)) else getattr(link, "tags_str", "") tag_html = "".join(f'{escape(tag)}' for tag in (tags_str or "").split(",") if tag) if tag_html: @@ -682,6 +643,7 @@ def snapshot_index_row(context, link) -> str: {escape(url)} + Saved {escape(date_text)} at {escape(time_text)} {tag_cell} @@ -706,9 +668,16 @@ def snapshot_index_row(context, link) -> str: @register.simple_tag(takes_context=True) -def snapshot_preview_url(context, snapshot, path: str = "") -> str: +def snapshot_preview_url(context, snapshot, path: str = "", result=None) -> str: snapshot_id = _snapshot_id(snapshot) - return _build_snapshot_preview_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG")) + plugin = getattr(result, "plugin", "") if result else "" + return _build_snapshot_preview_url( + str(snapshot_id), + path, + request=context.get("request"), + config=context.get("CONFIG"), + plugin=plugin, + ) @register.simple_tag @@ -789,8 +758,8 @@ def plugin_card(context, result) -> str: # Only return non-empty content (strip whitespace to check) if rendered.strip(): return mark_safe(rendered) - except Exception: - pass + except (template.TemplateSyntaxError, AttributeError, TypeError, ValueError): + rendered = "" if force_text_preview: preview = _render_text_file_preview(result.snapshot_dir, raw_output_path, plugin, icon_html) @@ -865,7 +834,7 @@ def plugin_full(context, result) -> str: if rendered.strip(): return mark_safe(rendered) return "" - except Exception: + except (template.TemplateSyntaxError, AttributeError, TypeError, ValueError): return "" diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 857d7170..d154ac25 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -3,71 +3,50 @@ __package__ = "archivebox.core" import json import os import posixpath -from glob import glob, escape -from django.utils import timezone -from typing import cast +from glob import escape, glob from pathlib import Path +from typing import ClassVar, cast from urllib.parse import quote, urlparse -from django.shortcuts import render, redirect -from django.http import HttpRequest, HttpResponse, Http404, HttpResponseForbidden, QueryDict -from django.utils.html import format_html, format_html_join -from django.utils.safestring import mark_safe -from django.views import View -from django.views.generic.list import ListView -from django.views.generic import FormView -from django.db.models import Case, IntegerField, Q, Value, When -from django.core.paginator import InvalidPage -from django.contrib import messages +from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay +from admin_data_views.typing import ItemContext, SectionData, TableContext +from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view +from django import template from django.conf import settings +from django.contrib import messages from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY, get_user_model from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.sessions.models import Session from django.core import signing -from django.views.decorators.csrf import csrf_exempt +from django.core.paginator import InvalidPage +from django.db.models import Case, IntegerField, Q, Value, When +from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidden, QueryDict +from django.shortcuts import redirect, render +from django.utils import timezone from django.utils.decorators import method_decorator - -from admin_data_views.typing import TableContext, ItemContext, SectionData -from admin_data_views.utils import render_with_table_view, render_with_item_view, ItemLink - -from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import FormView +from django.views.generic.list import ListView from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, VERSION from archivebox.config.common import ( + PLUGIN_CONFIG_SCHEMAS, SENSITIVE_CONFIG_VALUE_REDACTED, + _plugin_config_properties, find_config_default, find_config_section, find_config_source, find_config_type, - get_config, get_all_configs, + get_config, get_request_config, - _plugin_config_properties, redact_sensitive_config, ) -from archivebox.config.common import PLUGIN_CONFIG_SCHEMAS from archivebox.config.configset import BaseConfigSet -from archivebox.misc.paginators import AcceleratedPaginator -from archivebox.misc.util import ( - base_url, - filter_queryset_by_uuid_substring, - htmldecode, - sanitize_html_text, - ts_to_date_str, - urldecode, - validate_url, - without_fragment, -) -from archivebox.misc.serve_static import serve_static_with_byterange_support -from archivebox.misc.logging_util import printable_filesize -from archivebox.search.config import ( - get_search_mode, - get_search_mode_backend, - get_search_mode_base, - get_search_mode_options, -) -from archivebox.search.views import get_cached_public_search_state - +from archivebox.core.forms import AddLinkForm from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag from archivebox.core.permissions import ( PERMISSIONS_PRIVATE, @@ -90,11 +69,31 @@ from archivebox.core.routes_util import ( get_web_host, host_matches, ) -from archivebox.core.forms import AddLinkForm -from archivebox.plugins.forms import get_plugin_config_binary_urls from archivebox.crawls.models import Crawl +from archivebox.misc.logging_util import printable_filesize +from archivebox.misc.paginators import AcceleratedPaginator +from archivebox.misc.serve_static import serve_static_with_byterange_support +from archivebox.misc.util import ( + base_url, + filter_queryset_by_uuid_substring, + htmldecode, + sanitize_html_text, + ts_to_date_str, + urldecode, + validate_url, + without_fragment, +) +from archivebox.plugins.discovery import get_plugin_name, get_plugin_template +from archivebox.plugins.forms import get_plugin_config_binary_urls from archivebox.plugins.views import get_config_definition_link from archivebox.progressmonitor.views import live_progress_view, progress_endpoint +from archivebox.search.config import ( + get_search_mode, + get_search_mode_backend, + get_search_mode_base, + get_search_mode_options, +) +from archivebox.search.views import get_cached_public_search_state def _files_index_target(snapshot: Snapshot, archivefile: str | None) -> str: @@ -169,7 +168,7 @@ def _replay_payload_is_valid(payload: dict, snapshot: Snapshot) -> bool: user_id = str(session_data.get(SESSION_KEY) or "") auth_hash = str(session_data.get(HASH_SESSION_KEY) or "") user = get_user_model().objects.get(pk=user_id) - except Exception: + except (Session.DoesNotExist, get_user_model().DoesNotExist, KeyError, TypeError, ValueError): return False return ( str(payload.get("user_id")) == user_id @@ -343,7 +342,11 @@ class SnapshotView(View): for out in snapshot.discover_outputs(include_filesystem_fallback=True) if (out.get("size") or 0) > 0 and out.get("name") not in hidden_card_plugins ] - archiveresults = {out["name"]: out for out in outputs} + archiveresults = {} + for output in outputs: + current = archiveresults.get(output["name"]) + if current is None or (output.get("size") or 0) > (current.get("size") or 0): + archiveresults[output["name"]] = output hash_index = snapshot.hashes_index accounted_entries: set[str] = set() for output in outputs: @@ -446,6 +449,7 @@ class SnapshotView(View): "id": str(snapshot.id), "snapshot_id": str(snapshot.id), "progress_endpoint": progress_endpoint("snapshot", snapshot.id), + "progress_auto_expand": snapshot_status in {"queued", "started", "paused"}, "url": snapshot.url, "archive_path": snapshot.archive_path_from_db, "title": htmldecode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)), @@ -632,10 +636,7 @@ class SnapshotView(View): return SnapshotView.find_snapshots_for_url(slug) try: - try: - snapshot = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path)).get() - except Snapshot.DoesNotExist: - raise + snapshot = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path)).get() except Snapshot.DoesNotExist: return HttpResponse( format_html( @@ -827,21 +828,23 @@ def _safe_archive_relpath(path: str) -> str | None: return cleaned -def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, bool]: +def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, ArchiveResult | None]: """Resolve plugin-relative output paths through ArchiveResult.output_files.""" parts = Path(rel_path).parts if len(parts) < 2: - return rel_path, False + return rel_path, None plugin = parts[0] plugin_relpath = posixpath.join(*parts[1:]) result = ( ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED) - .only("output_files") + .only("plugin", "output_files") .first() ) - if not result or not result.output_files: - return rel_path, False + if not result: + return rel_path, None + if not result.output_files: + return rel_path, result output_files = result.output_files or {} for candidate in (plugin_relpath, rel_path): @@ -849,10 +852,79 @@ def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[s if not isinstance(file_info, dict): continue if file_info.get("root_relative"): - return candidate, True - return rel_path, True + return candidate, result + return rel_path, result - return rel_path, True + return rel_path, result + + +def _plugin_full_preview_response( + request: HttpRequest, + snapshot: Snapshot, + rel_path: str, + result: ArchiveResult | None, +) -> HttpResponse | None: + """Render an explicit plugin full template as a trusted preview wrapper.""" + if not request.GET.get("preview"): + return None + + path_parts = Path(rel_path).parts + plugin = get_plugin_name(result.plugin) if result else (path_parts[0] if len(path_parts) > 1 else "") + if not plugin: + return None + + # ReplayWeb.page needs plugin-owned WACZ inspection and service-worker + # context, so it remains the one narrow preview exception below. + if plugin == "archivewebpage" and archivewebpage_replay.is_replay_target(rel_path): + return None + + template_str = get_plugin_template(plugin, "full", fallback=False) + if not template_str: + return None + + raw_query = request.GET.copy() + raw_query.pop("preview", None) + output_url = request.path + if raw_query: + output_url = f"{output_url}?{raw_query.urlencode()}" + + rendered = ( + template.Engine(debug=False) + .from_string(template_str) + .render( + template.Context( + { + "result": result, + "snapshot": snapshot, + "output_path": output_url, + "output_path_raw": rel_path, + "plugin": plugin, + "preview_base": f"{request.path.rsplit('/', 1)[0]}/", + }, + ), + ) + ) + response = HttpResponse(rendered, content_type="text/html; charset=utf-8") + response.headers["Content-Disposition"] = f'inline; filename="{Path(rel_path).stem}.html"' + 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" + 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:; " + "connect-src 'self' data: blob:; " + "img-src 'self' data: blob:; " + "media-src 'self' data: blob:; " + "font-src 'self' data: blob:; " + "frame-src 'self' data: blob:; " + "worker-src 'self' blob:; " + "object-src 'none'; " + "base-uri 'none'; " + "form-action 'none'; " + "frame-ancestors 'self';" + ) + return response def _coerce_sort_timestamp(value: str | float | None) -> float: @@ -872,7 +944,7 @@ def _snapshot_sort_key(match_path: str, cache: dict[str, float]) -> tuple[float, idx = parts.index("snapshots") date_str = parts[idx + 1] snapshot_id = parts[idx + 3] - except Exception: + except (IndexError, ValueError): return (_coerce_sort_timestamp(date_str), match_path) if snapshot_id not in cache: @@ -1048,7 +1120,11 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = if rel_path is None: raise Http404 - rel_path, _is_known_plugin_output = _resolve_archiveresult_relpath(snapshot, rel_path) + rel_path, archive_result = _resolve_archiveresult_relpath(snapshot, rel_path) + + plugin_preview = _plugin_full_preview_response(request, snapshot, rel_path, archive_result) + if plugin_preview is not None: + return plugin_preview try: return serve_static_with_byterange_support( @@ -1172,7 +1248,7 @@ class OriginalDomainReplayView(View): class PublicIndexView(ListView): template_name = "public_index.html" model = Snapshot - ordering = ["-bookmarked_at", "-created_at"] + ordering: ClassVar[list[str]] = ["-bookmarked_at", "-created_at"] paginator_class = AcceleratedPaginator public_page_scan_chunk_size = 50 @@ -1634,13 +1710,16 @@ class WebAddView(AddView): return redirect(f"/{snapshot.url_path}") request_host = (request.get_host() or "").lower() - if request.user.is_authenticated and not get_request_config(request).PUBLIC_ADD_VIEW and host_matches(request_host, get_web_host()): + request_config = get_request_config(request) + web_host = get_web_host(config=request_config) + admin_host = get_admin_host(config=request_config) + if request.user.is_authenticated and not request_config.PUBLIC_ADD_VIEW and host_matches(request_host, web_host): return redirect(build_admin_url(request.get_full_path(), request=request)) if not self.test_func(): - if host_matches(request_host, get_web_host()): + if host_matches(request_host, web_host): return redirect(build_admin_url(request.get_full_path(), request=request)) - if host_matches(request_host, get_admin_host()): + if host_matches(request_host, admin_host): next_url = quote(request.get_full_path(), safe="/:?=&") return redirect(f"{build_admin_url('/admin/login/', request=request)}?next={next_url}") return HttpResponse( @@ -1733,7 +1812,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: } for section_id, section in reversed(list(CONFIGS.items())): - for key in dict(section).keys(): + for key in dict(section): rows["Section"].append(section_id) # section.replace('_', ' ').title().replace(' Config', '') rows["Key"].append(ItemLink(key, key=key)) rows["Type"].append(format_html("{}", find_config_type(key))) @@ -1756,7 +1835,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: ) section = "CONSTANT" - for key in CONSTANTS_CONFIG.keys(): + for key in CONSTANTS_CONFIG: rows["Section"].append(section) # section.replace('_', ' ').title().replace(' Config', '') rows["Key"].append(ItemLink(key, key=key)) rows["Type"].append(format_html("{}", type(CONSTANTS_CONFIG[key]).__name__)) diff --git a/archivebox/crawls/admin.py b/archivebox/crawls/admin.py index 9f43c48d..dd9eaa14 100644 --- a/archivebox/crawls/admin.py +++ b/archivebox/crawls/admin.py @@ -1,27 +1,25 @@ __package__ = "archivebox.crawls" -from copy import copy import json +from copy import copy +from typing import ClassVar from urllib.parse import urlencode, urlparse from django import forms -from django.core.paginator import Paginator -from django.http import JsonResponse, HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.template.loader import render_to_string -from django.urls import path, reverse -from django.utils.html import escape, format_html, format_html_join -from django.utils import timezone -from django.utils.safestring import mark_safe from django.contrib import admin, messages +from django.core.paginator import Paginator from django.db.models import Count, F, Q - - +from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse +from django.shortcuts import get_object_or_404, redirect +from django.template.loader import render_to_string +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils import timezone +from django.utils.html import escape, format_html, format_html_join +from django.utils.safestring import mark_safe from django_object_actions import action from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin - from archivebox.core.models import ArchiveResult, Snapshot from archivebox.core.permissions import ( PERMISSIONS_CHOICES, @@ -397,7 +395,7 @@ class CrawlAdminForm(forms.ModelForm): class Meta: model = Crawl fields = "__all__" - widgets = { + widgets: ClassVar[dict[str, forms.Widget]] = { "urls": forms.Textarea( attrs={ "rows": 8, @@ -704,16 +702,16 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): ) list_filter = (MaxDepthListFilter, "schedule", "created_by", "status", "retry_at") - ordering = ["-created_at", "-retry_at"] + ordering = ("-created_at", "-retry_at") list_per_page = 50 - actions = [ + actions = ( "pause_selected_crawls", "resume_selected_crawls", "seal_selected_crawls", "delete_selected_batched", "set_crawl_permissions", - ] - change_actions = ["recrawl"] + ) + change_actions = ("recrawl",) def __init__(self, model, admin_site): super().__init__(model, admin_site) @@ -721,8 +719,8 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): self.stop_reason_cache = {} class Media: - css = {"all": ("admin/crawls/crawl_change.css",)} - js = ("admin/crawls/crawl_admin.js",) + css: ClassVar[dict[str, tuple[str, ...]]] = {"all": ("admin/crawls/crawl_change.css",)} + js: ClassVar[tuple[str, ...]] = ("admin/crawls/crawl_admin.js",) def changelist_view(self, request, extra_context=None): self.request = request @@ -1219,6 +1217,7 @@ class CrawlScheduleAdmin(BaseModelAdmin): search_fields = ("id", "created_by__username", "label", "notes", "schedule_id", "template_id", "template__urls") readonly_fields = ("created_at", "modified_at", "crawls", "snapshots") + autocomplete_fields = ("template", "created_by") fieldsets = ( ( @@ -1259,9 +1258,9 @@ class CrawlScheduleAdmin(BaseModelAdmin): ) list_filter = ("created_by",) - ordering = ["-created_at"] + ordering = ("-created_at",) list_per_page = 100 - actions = ["delete_selected"] + actions = ("delete_selected",) def get_queryset(self, request): self.request = request diff --git a/archivebox/machine/admin.py b/archivebox/machine/admin.py index 889f6769..ef9a4171 100644 --- a/archivebox/machine/admin.py +++ b/archivebox/machine/admin.py @@ -5,6 +5,7 @@ import shlex from pathlib import Path from django.contrib import admin, messages +from django.db import DatabaseError from django.db.models import DurationField, ExpressionWrapper, F from django.db.models.functions import Coalesce, Now from django.shortcuts import redirect @@ -14,9 +15,9 @@ from django.utils.safestring import mark_safe from django_object_actions import action from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin -from archivebox.misc.logging_util import printable_filesize from archivebox.machine.env_util import env_to_dotenv_text -from archivebox.machine.models import Machine, NetworkInterface, Binary, Process +from archivebox.machine.models import Binary, Machine, NetworkInterface, Process +from archivebox.misc.logging_util import printable_filesize def _render_copy_block(text: str, *, multiline: bool = False): @@ -102,6 +103,21 @@ class MachineAdmin(ConfigEditorMixin, BaseModelAdmin): "os_release", "hw_uuid", ) + search_fields = ( + "id", + "guid", + "hostname", + "networkinterface__ip_public", + "networkinterface__ip_local", + "hw_manufacturer", + "hw_product", + "hw_uuid", + "os_platform", + "os_family", + "os_arch", + "os_release", + "os_kernel", + ) readonly_fields = ("guid", "created_at", "modified_at", "ips") @@ -162,9 +178,9 @@ class MachineAdmin(ConfigEditorMixin, BaseModelAdmin): ) list_filter = ("hw_in_docker", "hw_in_vm", "os_arch", "os_family", "os_platform") - ordering = ["-created_at"] + ordering = ("-created_at",) list_per_page = 100 - actions = ["delete_selected"] + actions = ("delete_selected",) @admin.display(description="Public IP", ordering="networkinterface__ip_public") def ips(self, machine): @@ -192,7 +208,7 @@ class MachineAdmin(ConfigEditorMixin, BaseModelAdmin): try: current_id = str(Machine.current().pk) - except Exception: + except (DatabaseError, RuntimeError, TypeError, ValueError): current_id = None machine_id = str(machine.pk) @@ -297,9 +313,9 @@ class NetworkInterfaceAdmin(BaseModelAdmin): ) list_filter = ("isp", "country", "region") - ordering = ["-created_at"] + ordering = ("-created_at",) list_per_page = 100 - actions = ["delete_selected"] + actions = ("delete_selected",) @admin.display(description="Machine", ordering="machine__id") def machine_info(self, iface): @@ -370,9 +386,9 @@ class BinaryAdmin(BaseModelAdmin): ) list_filter = ("name", "binprovider", "status", "machine_id") - ordering = ["-created_at"] + ordering = ("-created_at",) list_per_page = 100 - actions = ["delete_selected"] + actions = ("delete_selected",) @admin.display(description="Machine", ordering="machine__id") def machine_info(self, binary): @@ -492,10 +508,10 @@ class ProcessAdmin(BaseModelAdmin): ) list_filter = ("status", "exit_code", "machine_id") - ordering = ["-created_at"] + ordering = ("-created_at",) list_per_page = 100 - actions = ["kill_processes", "delete_selected"] - change_actions = ["kill_process"] + actions = ("kill_processes", "delete_selected") + change_actions = ("kill_process",) def get_queryset(self, request): return ( @@ -694,7 +710,7 @@ class ProcessAdmin(BaseModelAdmin): if isinstance(output_files, str): try: output_files = json.loads(output_files) - except Exception: + except (json.JSONDecodeError, TypeError, ValueError): output_files = {} file_count = 0 diff --git a/archivebox/mcp/server.py b/archivebox/mcp/server.py index 26196b79..5888f710 100644 --- a/archivebox/mcp/server.py +++ b/archivebox/mcp/server.py @@ -1,13 +1,13 @@ -""" -Model Context Protocol (MCP) server implementation for ArchiveBox. +"""Model Context Protocol server for ArchiveBox. -Dynamically exposes all ArchiveBox CLI commands as MCP tools by introspecting -Click command metadata. Handles JSON-RPC 2.0 requests over stdio transport. +The server exposes five agent-friendly workflow tools backed by ArchiveBox's +existing Click CLI and newline-delimited JSON-RPC. """ -import sys import json +import sys import traceback +from dataclasses import dataclass from typing import Any import click @@ -15,388 +15,553 @@ from click.testing import CliRunner from archivebox.config.version import VERSION +PROTOCOL_VERSION = "2025-11-25" +PUBLIC_TOOLS = ("add", "search", "crawl", "snapshot", "archiveresult", "shell") +ACTION_TOOLS = {"crawl", "snapshot", "archiveresult"} +READ_ONLY_ACTIONS = {"help", "list", "search", "status", "version"} +DESTRUCTIVE_ACTIONS = {"delete", "remove"} + class MCPJSONEncoder(json.JSONEncoder): - """Custom JSON encoder that handles Click sentinel values and other special types""" + """JSON encoder for UUIDs, paths, Click sentinels, and other CLI values.""" - def default(self, o): - # Handle Click's sentinel values + def default(self, value): sentinel_type = getattr(click.core, "_SentinelClass", None) - if isinstance(sentinel_type, type) and isinstance(o, sentinel_type): + if isinstance(sentinel_type, type) and isinstance(value, sentinel_type): return None - - # Handle tuples (convert to lists) - if isinstance(o, tuple): - return list(o) - - # Handle any other non-serializable objects + if isinstance(value, tuple): + return list(value) try: - return super().default(o) + return super().default(value) except TypeError: - return str(o) + return str(value) + + +@dataclass(frozen=True) +class MCPTool: + """A discovered leaf Click command and its ArchiveBox command path.""" + + name: str + command_path: tuple[str, ...] + command: click.Command -# Type mapping from Click types to JSON Schema types def click_type_to_json_schema_type(click_type: click.ParamType) -> dict[str, Any]: - """Convert a Click parameter type to JSON Schema type definition""" + """Convert a Click parameter type to JSON Schema.""" if isinstance(click_type, click.types.StringParamType): return {"type": "string"} - elif isinstance(click_type, click.types.IntParamType): + if isinstance(click_type, click.types.IntParamType): return {"type": "integer"} - elif isinstance(click_type, click.types.FloatParamType): + if isinstance(click_type, click.types.FloatParamType): return {"type": "number"} - elif isinstance(click_type, click.types.BoolParamType): + if isinstance(click_type, click.types.BoolParamType): return {"type": "boolean"} - elif isinstance(click_type, click.types.Choice): - return {"type": "string", "enum": list(click_type.choices)} - elif isinstance(click_type, click.types.Path): + if isinstance(click_type, click.types.Choice): + schema: dict[str, Any] = {"enum": list(click_type.choices)} + if all(isinstance(choice, str) for choice in click_type.choices): + schema["type"] = "string" + return schema + if isinstance(click_type, (click.types.Path, click.types.File)): return {"type": "string", "description": "File or directory path"} - elif isinstance(click_type, click.types.File): - return {"type": "string", "description": "File path"} - elif isinstance(click_type, click.types.Tuple): - # Multiple arguments of same type - return {"type": "array", "items": {"type": "string"}} - else: - # Default to string for unknown types - return {"type": "string"} + if isinstance(click_type, click.types.Tuple): + return { + "type": "array", + "prefixItems": [click_type_to_json_schema_type(item) for item in click_type.types], + "minItems": len(click_type.types), + "maxItems": len(click_type.types), + } + return {"type": "string"} -def click_command_to_mcp_tool(cmd_name: str, click_command: click.Command) -> dict[str, Any]: - """ - Convert a Click command to an MCP tool definition with JSON Schema. +def command_accepts_stdin(click_command: click.Command) -> bool: + """Return whether a command documents a stdin/JSONL input path.""" - Introspects the Click command's parameters to automatically generate - the input schema without manual definition. - """ + command_help = " ".join( + value + for value in ( + click_command.help, + click_command.short_help, + click_command.callback.__doc__ if click_command.callback else None, + ) + if value + ).lower() + return "stdin" in command_help + + +def tool_annotations(command_path: tuple[str, ...]) -> dict[str, Any]: + """Describe a command's side effects to MCP clients.""" + + action = command_path[-1] + root = command_path[0] + read_only = action in READ_ONLY_ACTIONS + destructive = action in DESTRUCTIVE_ACTIONS + return { + "title": "ArchiveBox " + " ".join(command_path), + "readOnlyHint": read_only, + "destructiveHint": destructive, + "idempotentHint": read_only or destructive, + "openWorldHint": root in {"add", "extract", "oneshot", "run", "update"} or (root in {"crawl", "snapshot"} and action == "create"), + } + + +def click_command_to_mcp_tool(tool: MCPTool) -> dict[str, Any]: + """Convert a leaf Click command to an MCP tool definition.""" properties: dict[str, dict[str, Any]] = {} required: list[str] = [] - # Extract parameters from Click command - for param in click_command.params: - # Skip internal parameters - if param.name is None or param.name in ("help", "version"): + for param in tool.command.params: + if param.name is None or param.name in {"help", "version"}: continue param_schema = click_type_to_json_schema_type(param.type) - - # Add description from Click help text help_text = getattr(param, "help", None) if help_text: param_schema["description"] = help_text - # Handle default values - if param.default is not None and param.default != (): - param_schema["default"] = param.default + default = param.default + sentinel_type = getattr(click.core, "_SentinelClass", None) + is_sentinel = isinstance(sentinel_type, type) and isinstance(default, sentinel_type) + if default is not None and default != () and not is_sentinel: + param_schema["default"] = default - # Handle multiple values (like multiple URLs) - if param.multiple: - properties[param.name] = { - "type": "array", - "items": param_schema, - "description": param_schema.get("description", f"Multiple {param.name} values"), - } + if param.multiple or param.nargs != 1: + item_schema = param_schema + if isinstance(param.type, click.types.Tuple): + properties[param.name] = param_schema + else: + properties[param.name] = { + "type": "array", + "items": item_schema, + "description": help_text or f"One or more {param.name.replace('_', ' ')} values", + } else: properties[param.name] = param_schema - # Mark as required if Click requires it if param.required: required.append(param.name) + if command_accepts_stdin(tool.command): + properties["records"] = { + "type": "array", + "items": {"type": "object"}, + "description": "Records to pass to the command as JSONL stdin. Use this instead of building a CLI pipeline.", + } + properties["stdin"] = { + "type": "string", + "description": "Raw stdin text. Prefer records for JSONL commands.", + } + + command_name = "archivebox " + " ".join(tool.command_path) + description = tool.command.help or tool.command.short_help or f"Run {command_name}" return { - "name": cmd_name, - "description": click_command.help or click_command.short_help or f"Run archivebox {cmd_name} command", + "name": tool.name, + "title": " ".join(part.title() for part in tool.command_path), + "description": f"{description}\n\nEquivalent CLI command: `{command_name}`.", "inputSchema": { "type": "object", "properties": properties, "required": required, + "additionalProperties": False, + }, + "outputSchema": tool_output_schema(), + "annotations": tool_annotations(tool.command_path), + } + + +def tool_output_schema() -> dict[str, Any]: + """Return the shared envelope schema for all CLI-backed tools.""" + + return { + "type": "object", + "properties": { + "command": {"type": "string"}, + "success": {"type": "boolean"}, + "error": {"type": ["string", "null"]}, + "exitCode": {"type": "integer"}, + "records": {"type": "array", "items": {}}, + "stdout": {"type": "string"}, + "stderr": {"type": "string"}, + }, + "required": ["command", "success", "error", "exitCode", "records", "stdout", "stderr"], + } + + +def click_group_to_mcp_tool(group_name: str, actions: list[MCPTool]) -> dict[str, Any]: + """Expose a Click command group as one MCP tool with an action selector.""" + + action_names = [tool.command_path[-1] for tool in actions] + properties: dict[str, dict[str, Any]] = { + "action": { + "type": "string", + "enum": action_names, + "description": f"{group_name.title()} action to run.", + }, + } + action_help = [] + for action_tool in actions: + action_def = click_command_to_mcp_tool(action_tool) + action_name = action_tool.command_path[-1] + action_help.append(f"- {action_name}: {action_tool.command.help or action_tool.command.short_help}") + for name, schema in action_def["inputSchema"]["properties"].items(): + if name not in properties: + properties[name] = dict(schema) + elif properties[name].get("default") != schema.get("default"): + properties[name].pop("default", None) + + return { + "name": group_name, + "title": f"ArchiveBox {group_name.title()}", + "description": (f"Manage ArchiveBox {group_name} records through the existing CLI.\n\nActions:\n{chr(10).join(action_help)}"), + "inputSchema": { + "type": "object", + "properties": properties, + "required": ["action"], + "additionalProperties": False, + }, + "outputSchema": tool_output_schema(), + "annotations": {"title": f"ArchiveBox {group_name.title()}"}, + } + + +def shell_to_mcp_tool() -> dict[str, Any]: + """Expose ``archivebox shell -c`` as one explicit Python escape hatch.""" + + return { + "name": "shell", + "title": "ArchiveBox Python Shell", + "description": ( + "Run arbitrary Python with ArchiveBox and Django initialized. " + "Equivalent CLI command: `archivebox shell --plain --quiet-load -c CODE`. " + "This has full access to the collection database and filesystem." + ), + "inputSchema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python source to execute in the initialized ArchiveBox Django shell.", + }, + }, + "required": ["code"], + "additionalProperties": False, + }, + "outputSchema": tool_output_schema(), + "annotations": { + "title": "ArchiveBox Python Shell", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, }, } -def execute_click_command(cmd_name: str, click_command: click.Command, arguments: dict) -> dict: - """ - Execute a Click command programmatically with given arguments. +def _option_args(param: click.Option, value: Any) -> list[str]: + """Serialize one MCP argument using the option's real Click spellings.""" - Returns MCP-formatted result with captured output and error status. - """ + primary = next((opt for opt in param.opts if opt.startswith("--")), param.opts[0]) + if param.is_bool_flag: + if value: + return [primary] + if param.secondary_opts: + secondary = next((opt for opt in param.secondary_opts if opt.startswith("--")), param.secondary_opts[0]) + return [secondary] + return [] - # Setup Django for archive commands (commands that need database access) - from archivebox.cli import ArchiveBoxGroup + values = value if isinstance(value, list) and param.multiple else [value] + args: list[str] = [] + for item in values: + args.extend((primary, str(item))) + return args - if cmd_name in ArchiveBoxGroup.archive_commands: - try: - from archivebox.config.django import setup_django - from archivebox.misc.checks import check_data_folder - setup_django() - check_data_folder() - except Exception as e: - # If Django setup fails, return error (unless it's manage/shell which handle this themselves) - if cmd_name not in ("manage", "shell"): - return { - "content": [ - { - "type": "text", - "text": f"Error setting up Django: {str(e)}\n\nMake sure you're running the MCP server from inside an ArchiveBox data directory.", - }, - ], - "isError": True, - } +def arguments_to_cli( + click_command: click.Command, + arguments: dict[str, Any], +) -> tuple[list[str], str | None]: + """Convert MCP JSON arguments into Click argv and optional stdin.""" - # Use Click's test runner to invoke command programmatically - runner = CliRunner() + supplied = dict(arguments) + records = supplied.pop("records", None) + stdin_text = supplied.pop("stdin", None) + if records is not None and stdin_text is not None: + raise ValueError("Pass either records or stdin, not both") + if records is not None: + stdin_text = "".join(json.dumps(record, cls=MCPJSONEncoder) + "\n" for record in records) - # Build a map of parameter names to their Click types (Argument vs Option) param_map = {param.name: param for param in click_command.params} + unknown = sorted(set(supplied) - set(param_map)) + if unknown: + raise ValueError(f"Unknown argument(s): {', '.join(unknown)}") - # Convert arguments dict to CLI args list - args = [] - positional_args = [] - - for key, value in arguments.items(): - param_name = key.replace("_", "-") # Click uses dashes - param = param_map.get(key) - - # Check if this is a positional Argument (not an Option) - is_argument = isinstance(param, click.Argument) - - if is_argument: - # Positional arguments - add them without dashes - if isinstance(value, list): - positional_args.extend([str(v) for v in value]) - elif value is not None: - positional_args.append(str(value)) + option_args: list[str] = [] + positional_args: list[str] = [] + for key, value in supplied.items(): + if value is None: + continue + param = param_map[key] + if isinstance(param, click.Argument): + values = value if isinstance(value, list) else [value] + positional_args.extend(str(item) for item in values) else: - # Options - add with dashes - if isinstance(value, bool): - if value: - args.append(f"--{param_name}") - elif isinstance(value, list): - # Multiple values for an option (rare) - for item in value: - args.append(f"--{param_name}") - args.append(str(item)) - elif value is not None: - args.append(f"--{param_name}") - args.append(str(value)) + option_args.extend(_option_args(param, value)) - # Add positional arguments at the end - args.extend(positional_args) + return [*option_args, *positional_args], stdin_text - # Execute the command + +def parse_structured_records(stdout: str) -> list[Any]: + """Parse a CLI JSON array or JSONL stream without guessing at human output.""" + + stripped = stdout.strip() + if not stripped: + return [] try: - result = runner.invoke(click_command, args, catch_exceptions=False) + parsed = json.loads(stripped) + except json.JSONDecodeError: + records = [] + for line in stripped.splitlines(): + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + return [] + return records + return parsed if isinstance(parsed, list) else [parsed] - # Format output as MCP content - content = [] - if result.output: - content.append( - { - "type": "text", - "text": result.output, - }, - ) +def execute_click_command(tool: MCPTool, arguments: dict[str, Any]) -> dict[str, Any]: + """Execute one discovered CLI command and return MCP structured content.""" - if result.stderr_bytes: - stderr_text = result.stderr_bytes.decode("utf-8", errors="replace") - if stderr_text.strip(): - content.append( - { - "type": "text", - "text": f"[stderr]\n{stderr_text}", - }, - ) + from archivebox.cli import cli - # Check exit code - is_error = result.exit_code != 0 + try: + cli_args, stdin_text = arguments_to_cli(tool.command, arguments) + result = CliRunner().invoke( + cli, + [*tool.command_path, *cli_args], + input=stdin_text, + prog_name="archivebox", + catch_exceptions=False, + ) + return _tool_result( + tool, + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + is_error=result.exit_code != 0, + ) + except (click.ClickException, click.UsageError, OSError, SystemExit, ValueError) as err: + print(traceback.format_exc(), file=sys.stderr) + return _tool_result( + tool, + exit_code=1, + stderr=f"Could not invoke archivebox {' '.join(tool.command_path)}: {err}", + is_error=True, + ) - if is_error and not content: - content.append( - { - "type": "text", - "text": f"Command failed with exit code {result.exit_code}", - }, - ) - return { - "content": content or [{"type": "text", "text": "(no output)"}], - "isError": is_error, - } - - except Exception as e: - # Capture any exceptions during execution - error_trace = traceback.format_exc() - return { - "content": [ - { - "type": "text", - "text": f"Error executing {cmd_name}: {str(e)}\n\n{error_trace}", - }, - ], - "isError": True, - } +def _tool_result( + tool: MCPTool, + *, + exit_code: int, + stdout: str = "", + stderr: str = "", + is_error: bool, +) -> dict[str, Any]: + error = None + if is_error: + error = stderr.strip() or stdout.strip() or f"Command failed with exit code {exit_code}" + structured = { + "command": "archivebox " + " ".join(tool.command_path), + "success": not is_error, + "error": error, + "exitCode": exit_code, + "records": parse_structured_records(stdout), + "stdout": stdout, + "stderr": stderr, + } + return { + "content": [{"type": "text", "text": json.dumps(structured, cls=MCPJSONEncoder)}], + "structuredContent": structured, + "isError": is_error, + } class MCPServer: - """ - Model Context Protocol server for ArchiveBox. - - Provides JSON-RPC 2.0 interface over stdio, dynamically exposing - all Click commands as MCP tools. - """ + """ArchiveBox MCP server using JSON-RPC 2.0 over stdio.""" def __init__(self): - # Import here to avoid circular imports from archivebox.cli import ArchiveBoxGroup self.cli_group = ArchiveBoxGroup() - self.protocol_version = "2025-11-25" - self._tool_cache = {} # Cache loaded Click commands + self.protocol_version = PROTOCOL_VERSION + self._tools: dict[str, MCPTool] | None = None - def get_click_command(self, cmd_name: str) -> click.Command | None: - """Get a Click command by name, with caching""" - if cmd_name not in self._tool_cache: - if cmd_name not in self.cli_group.all_subcommands: - return None - self._tool_cache[cmd_name] = self.cli_group.get_command(click.Context(self.cli_group), cmd_name) - return self._tool_cache[cmd_name] + def _discover_command( + self, + command: click.Command, + command_path: tuple[str, ...], + tools: dict[str, MCPTool], + ) -> None: + if isinstance(command, click.Group): + context = click.Context(command) + for child_name in command.list_commands(context): + child = command.get_command(context, child_name) + if child is not None: + self._discover_command(child, (*command_path, child_name), tools) + return + + tool_name = "_".join(command_path).replace("-", "_") + tools[tool_name] = MCPTool(tool_name, command_path, command) + + def get_tools(self) -> dict[str, MCPTool]: + """Discover and cache the CLI commands backing the five public tools.""" + + if self._tools is None: + tools: dict[str, MCPTool] = {} + context = click.Context(self.cli_group) + for command_name in PUBLIC_TOOLS: + command = self.cli_group.get_command(context, command_name) + if command is not None: + self._discover_command(command, (command_name,), tools) + self._tools = tools + return self._tools + + def get_public_tool_definitions(self) -> list[dict[str, Any]]: + """Build the small agent-facing surface from the existing Click tree.""" + + commands = self.get_tools() + definitions = [ + shell_to_mcp_tool() if name == "shell" else click_command_to_mcp_tool(commands[name]) + for name in PUBLIC_TOOLS + if name not in ACTION_TOOLS + ] + for group_name in PUBLIC_TOOLS: + if group_name not in ACTION_TOOLS: + continue + actions = sorted( + (tool for tool in commands.values() if tool.command_path[0] == group_name), + key=lambda tool: tool.command_path, + ) + definitions.append(click_group_to_mcp_tool(group_name, actions)) + return definitions def handle_initialize(self, params: dict) -> dict: - """Handle MCP initialize request""" return { "protocolVersion": self.protocol_version, - "capabilities": { - "tools": {}, - }, + "capabilities": {"tools": {"listChanged": False}}, "serverInfo": { "name": "archivebox-mcp", + "title": "ArchiveBox", "version": VERSION, }, + "instructions": ( + "Use add to archive URLs and search for deep search. Use crawl, snapshot, or archiveresult " + "with action=create/list/update/delete to manage records. Pass returned records directly " + "to update/delete actions through the records argument. Use shell only when the curated " + "tools cannot express the operation; it runs arbitrary Python with full collection access." + ), } def handle_tools_list(self, params: dict) -> dict: - """Handle MCP tools/list request - returns all available CLI commands as tools""" - tools = [] - - for cmd_name in self.cli_group.all_subcommands.keys(): - click_cmd = self.get_click_command(cmd_name) - if click_cmd: - try: - tool_def = click_command_to_mcp_tool(cmd_name, click_cmd) - tools.append(tool_def) - except Exception as e: - # Log but don't fail - skip problematic commands - print(f"Warning: Could not generate tool for {cmd_name}: {e}", file=sys.stderr) - - return {"tools": tools} + return {"tools": self.get_public_tool_definitions()} def handle_tools_call(self, params: dict) -> dict: - """Handle MCP tools/call request - executes a CLI command""" tool_name = params.get("name") - arguments = params.get("arguments", {}) - if not tool_name: raise ValueError("Missing required parameter: name") - - click_cmd = self.get_click_command(tool_name) - if not click_cmd: + if tool_name not in PUBLIC_TOOLS: raise ValueError(f"Unknown tool: {tool_name}") + arguments = params.get("arguments", {}) + if not isinstance(arguments, dict): + raise TypeError("Tool arguments must be an object") + arguments = dict(arguments) + command_name = tool_name + if tool_name == "shell": + unknown = sorted(set(arguments) - {"code"}) + if unknown: + raise ValueError(f"Unknown shell argument(s): {', '.join(unknown)}") + code = arguments.get("code") + if not isinstance(code, str) or not code.strip(): + raise ValueError("shell requires a non-empty code string") + arguments = {"args": ["--plain", "--quiet-load", "-c", code]} + elif tool_name in ACTION_TOOLS: + action = arguments.pop("action", None) + available_actions = sorted(tool.command_path[-1] for tool in self.get_tools().values() if tool.command_path[0] == tool_name) + if action not in available_actions: + raise ValueError( + f"Unknown {tool_name} action: {action!r}. Choose one of: {', '.join(available_actions)}", + ) + command_name = f"{tool_name}_{action}" + tool = self.get_tools().get(command_name) + if tool is None: + raise ValueError(f"ArchiveBox CLI command is unavailable: {command_name}") + return execute_click_command(tool, arguments) - # Execute the command and return MCP-formatted result - return execute_click_command(tool_name, click_cmd, arguments) - - def handle_request(self, request: dict) -> dict: - """ - Handle a JSON-RPC 2.0 request and return response. - - Supports MCP methods: initialize, tools/list, tools/call - """ - + def handle_request(self, request: dict) -> dict | None: method = request.get("method") params = request.get("params", {}) request_id = request.get("id") + is_notification = "id" not in request + + if is_notification: + return None try: - # Route to appropriate handler if method == "initialize": result = self.handle_initialize(params) + elif method == "ping": + result = {} elif method == "tools/list": result = self.handle_tools_list(params) elif method == "tools/call": result = self.handle_tools_call(params) else: - # Method not found return { "jsonrpc": "2.0", "id": request_id, - "error": { - "code": -32601, - "message": f"Method not found: {method}", - }, + "error": {"code": -32601, "message": f"Method not found: {method}"}, } - - # Success response + return {"jsonrpc": "2.0", "id": request_id, "result": result} + except (TypeError, ValueError) as err: return { "jsonrpc": "2.0", "id": request_id, - "result": result, + "error": {"code": -32602, "message": str(err)}, } - - except Exception as e: - # Error response - error_trace = traceback.format_exc() + except (click.ClickException, click.UsageError, OSError, RuntimeError) as err: return { "jsonrpc": "2.0", "id": request_id, "error": { "code": -32603, - "message": str(e), - "data": error_trace, + "message": str(err), + "data": traceback.format_exc(), }, } - def run_stdio_server(self): - """ - Run the MCP server in stdio mode. + def run_stdio_server(self) -> None: + """Read and write one UTF-8 JSON-RPC message per line.""" - Reads JSON-RPC requests from stdin (one per line), - writes JSON-RPC responses to stdout (one per line). - """ - - # Read requests from stdin line by line for line in sys.stdin: - line = line.strip() - if not line: + if not line.strip(): continue - try: - # Parse JSON-RPC request request = json.loads(line) - - # Handle request response = self.handle_request(request) - - # Write response to stdout (use custom encoder for Click types) - print(json.dumps(response, cls=MCPJSONEncoder), flush=True) - - except json.JSONDecodeError as e: - # Invalid JSON - error_response = { + if response is not None: + print(json.dumps(response, cls=MCPJSONEncoder), flush=True) + except json.JSONDecodeError as err: + response = { "jsonrpc": "2.0", "id": None, - "error": { - "code": -32700, - "message": "Parse error", - "data": str(e), - }, + "error": {"code": -32700, "message": "Parse error", "data": str(err)}, } - print(json.dumps(error_response, cls=MCPJSONEncoder), flush=True) + print(json.dumps(response, cls=MCPJSONEncoder), flush=True) -def run_mcp_server(): - """Main entry point for MCP server""" - server = MCPServer() - server.run_stdio_server() +def run_mcp_server() -> None: + """Start the ArchiveBox MCP stdio server.""" + + MCPServer().run_stdio_server() diff --git a/archivebox/misc/serve_static.py b/archivebox/misc/serve_static.py index b0fc4a0d..5edf4658 100644 --- a/archivebox/misc/serve_static.py +++ b/archivebox/misc/serve_static.py @@ -1,36 +1,35 @@ -import html -import json -import re -import os -import sys -import stat import asyncio -import posixpath -import mimetypes +import html import importlib +import json +import mimetypes +import os +import posixpath import queue +import re +import stat +import sys import threading import time import zipfile -from datetime import datetime from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path from urllib.parse import urlencode -from django import template -from django.core.handlers.asgi import ASGIRequest +from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay from django.contrib.staticfiles import finders +from django.core.handlers.asgi import ASGIRequest +from django.http import Http404, HttpResponse, HttpResponseNotModified, StreamingHttpResponse from django.template import TemplateDoesNotExist, loader -from django.views import static -from django.http import StreamingHttpResponse, Http404, HttpResponse, HttpResponseNotModified from django.utils._os import safe_join from django.utils.http import http_date from django.utils.translation import gettext as _ -from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay +from django.views import static + from archivebox.config.common import get_config from archivebox.misc.logging_util import printable_filesize - _HASHES_CACHE: dict[Path, tuple[float, dict[str, str]]] = {} @@ -49,7 +48,7 @@ def _load_hash_map(snapshot_dir: Path) -> dict[str, str] | None: try: data = json.loads(hashes_path.read_text(encoding="utf-8")) - except Exception: + except (json.JSONDecodeError, OSError, TypeError, ValueError): return None file_map = {str(entry.get("path")): entry.get("hash") for entry in data.get("files", []) if entry.get("path")} @@ -99,28 +98,9 @@ def _cache_policy(config=None, **config_kwargs) -> str: return "private" if config.PERMISSIONS == "private" else "public" -def _render_mhtml_preview_document(filename: str, output_path: str) -> str: - from archivebox.plugins.discovery import get_plugin_template - - template_str = get_plugin_template("chrome_mhtml", "full", fallback=False) - if not template_str: - raise FileNotFoundError("chrome_mhtml/templates/full.html") - - tpl = template.Engine(debug=False).from_string(template_str) - return tpl.render( - template.Context( - { - "output_path": output_path, - "output_path_raw": filename, - "plugin": "chrome_mhtml", - }, - ), - ) - - def _format_direntry_timestamp(stat_result: os.stat_result) -> str: timestamp = stat_result.st_birthtime if sys.platform == "darwin" else stat_result.st_mtime - return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") + return datetime.fromtimestamp(timestamp, tz=UTC).strftime("%Y-%m-%d %H:%M") def _safe_zip_stem(name: str) -> str: @@ -190,7 +170,7 @@ def _build_directory_zip_response( rel_parts = entry.relative_to(fullpath).parts arcname = Path(root_name, *rel_parts).as_posix() zip_file.write(entry, arcname) - except BaseException as err: + except (OSError, RuntimeError, TypeError, ValueError, zipfile.BadZipFile) as err: output_queue.put(err) finally: output_queue.put(sentinel) @@ -490,7 +470,7 @@ def _render_markdown_fallback(text: str) -> str: extensions=["extra", "toc", "sane_lists"], output_format="html", ) - except Exception: + except (ImportError, RuntimeError, ValueError): pass lines = text.splitlines() @@ -668,7 +648,7 @@ def _is_risky_replay_document(fullpath: Path, content_type: str) -> bool: # so one-domain no-JS mode still catches HTML/SVG documents. try: head = fullpath.read_bytes()[:4096].decode("utf-8", errors="ignore").lower() - except Exception: + except (OSError, UnicodeDecodeError): return False return any(marker in head for marker in RISKY_REPLAY_MARKERS) @@ -690,7 +670,9 @@ def _apply_archive_replay_headers( 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): + is_risky_replay = _is_risky_replay_document(fullpath, content_type) + + if config.SHOULD_NEUTER_RISKY_REPLAY and is_risky_replay and "Content-Security-Policy" not in response.headers: response.headers["Content-Security-Policy"] = ( "sandbox; " "default-src 'self' data: blob:; " @@ -797,19 +779,20 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ preview_as_image_html = ( bool(request.GET.get("preview")) and content_type.startswith("image/") and not content_type.startswith("image/svg+xml") ) - preview_as_mhtml_html = bool(request.GET.get("preview")) and fullpath.suffix.lower() in {".mhtml", ".mht"} preview_as_archivewebpage_html = bool(request.GET.get("preview")) and archivewebpage_replay.is_replay_target(fullpath.name) # Respect the If-Modified-Since header for non-markdown responses. - if not (content_type.startswith("text/plain") or content_type.startswith("text/html")): - if not static.was_modified_since(request.META.get("HTTP_IF_MODIFIED_SINCE"), statobj.st_mtime): - return _apply_archive_replay_headers( - HttpResponseNotModified(), - fullpath=fullpath, - content_type=content_type, - is_archive_replay=is_archive_replay, - config=config, - ) + if not content_type.startswith(("text/plain", "text/html")) and not static.was_modified_since( + request.META.get("HTTP_IF_MODIFIED_SINCE"), + statobj.st_mtime, + ): + return _apply_archive_replay_headers( + HttpResponseNotModified(), + 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. if preview_as_text_html: @@ -835,8 +818,8 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ is_archive_replay=is_archive_replay, config=config, ) - except Exception: - pass + except (OSError, UnicodeDecodeError, ValueError): + preview_as_text_html = False if preview_as_image_html: try: @@ -863,8 +846,8 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ is_archive_replay=is_archive_replay, config=config, ) - except Exception: - pass + except (OSError, ValueError): + preview_as_image_html = False if preview_as_archivewebpage_html: try: @@ -890,46 +873,19 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ response = HttpResponse(body, content_type=preview_content_type) for key, value in headers.items(): response.headers[key] = value - return response - except Exception: - pass - - if preview_as_mhtml_html: - try: - raw_query = request.GET.copy() - raw_query.pop("preview", None) - raw_output_path = request.path - if raw_query: - raw_output_path = f"{raw_output_path}?{raw_query.urlencode()}" - rendered = _render_mhtml_preview_document(fullpath.name, raw_output_path) - response = HttpResponse(rendered, content_type="text/html; charset=utf-8") - response.headers["Last-Modified"] = http_date(statobj.st_mtime) - if etag: - response.headers["ETag"] = etag - response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable" - else: - 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"] = ( - "default-src 'self' data: blob:; " - "script-src 'unsafe-inline'; " - "style-src 'unsafe-inline' data: blob:; " - "connect-src 'self'; " - "frame-src 'self' data: blob:; " - "object-src 'none'; " - "base-uri 'none'; " - "form-action 'none';" + return _apply_archive_replay_headers( + response, + fullpath=fullpath, + content_type=preview_content_type, + is_archive_replay=is_archive_replay, + config=config, ) - if encoding: - response.headers["Content-Encoding"] = encoding - return response - except Exception: - pass + except (OSError, RuntimeError, ValueError): + preview_as_archivewebpage_html = False # Heuristic fix: some archived HTML outputs (e.g. mercury content.html) # are stored with HTML-escaped markup or markdown sources. If so, render sensibly. - if content_type.startswith("text/plain") or content_type.startswith("text/html"): + if content_type.startswith(("text/plain", "text/html")): try: max_unescape_size = 10 * 1024 * 1024 # 10MB cap to avoid heavy memory use if statobj.st_size <= max_unescape_size: @@ -977,11 +933,11 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ is_archive_replay=is_archive_replay, config=config, ) - except Exception: + except (OSError, UnicodeDecodeError, ValueError): pass # setup response object - ranged_file = RangedFileReader(open(fullpath, "rb")) + ranged_file = RangedFileReader(fullpath.open("rb")) response = StreamingHttpResponse( _stream_ranged_file_async(ranged_file) if _is_asgi_request(request) else ranged_file, content_type=content_type, @@ -1018,7 +974,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ return HttpResponse(status=416) ranged_file.start = start ranged_file.stop = stop - response["Content-Range"] = "bytes %d-%d/%d" % (start, stop - 1, size) + response["Content-Range"] = f"bytes {start}-{stop - 1}/{size}" response["Content-Length"] = stop - start response.status_code = 206 if encoding: @@ -1053,7 +1009,7 @@ def serve_static(request, path, **kwargs): if not absolute_path: if path.endswith("/") or path == "": raise Http404("Directory indexes are not allowed here.") - raise Http404("'%s' could not be found" % path) + raise Http404(f"'{path}' could not be found") document_root, path = os.path.split(absolute_path) return serve_static_with_byterange_support(request, path, document_root=document_root, **kwargs) @@ -1085,8 +1041,7 @@ def parse_range_header(header, resource_size): # suffix-byte-range-spec: this form specifies the last N bytes of an # entity-body start = resource_size + int(val) - if start < 0: - start = 0 + start = max(start, 0) stop = resource_size else: # byte-range-spec: first-byte-pos "-" [last-byte-pos] diff --git a/archivebox/plugins/views.py b/archivebox/plugins/views.py index e23a89c4..e5dc1744 100644 --- a/archivebox/plugins/views.py +++ b/archivebox/plugins/views.py @@ -3,22 +3,20 @@ __package__ = "archivebox.plugins" import html import json import re -from typing import Any from collections.abc import Callable +from typing import Any from urllib.parse import quote -from django.http import HttpRequest +from admin_data_views.typing import TableContext +from admin_data_views.utils import ItemLink, render_with_table_view +from django.http import HttpRequest, HttpResponseRedirect from django.utils.html import format_html from django.utils.safestring import mark_safe -from admin_data_views.typing import ItemContext, SectionData, TableContext -from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view - from archivebox.config.common import get_live_config_url from archivebox.config.views import get_environment_binary_url, is_superuser from archivebox.plugins.discovery import BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR, discover_plugin_configs, iter_plugin_dirs - ABX_PLUGINS_DOCS_BASE_URL = "https://archivebox.github.io/abx-plugins/" ABX_PLUGINS_GITHUB_BASE_URL = "https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/" LIVE_PLUGIN_BASE_URL = "/admin/environment/plugins/" @@ -372,83 +370,8 @@ def plugins_list_view(request: HttpRequest, **kwargs) -> TableContext: ) -@render_with_item_view -def plugin_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: +def plugin_detail_view(request: HttpRequest, key: str, **kwargs) -> HttpResponseRedirect: assert is_superuser(request), "Must be a superuser to view configuration settings." - plugins = get_filesystem_plugins() - - plugin = plugins.get(key) - if not plugin: - return ItemContext( - slug=key, - title=f"Plugin not found: {key}", - data=[], - ) - - docs_url = get_plugin_docs_url(plugin["name"]) - machine_admin_url = get_machine_admin_url() - fields = { - "id": plugin["id"], - "name": plugin["name"], - "source": plugin["source"], - } - - sections: list[SectionData] = [ - { - "name": plugin["name"], - "description": format_html( - '{}
ABX Plugin Docs', - plugin["path"], - docs_url, - ), - "fields": fields, - "help_texts": {}, - }, - ] - - if plugin["hooks"]: - sections.append( - { - "name": "Hooks", - "description": mark_safe(render_hook_links_html(plugin["name"], plugin["hooks"], plugin["source"])), - "fields": {}, - "help_texts": {}, - }, - ) - - if plugin.get("config"): - sections.append( - { - "name": "Plugin Metadata", - "description": mark_safe(render_plugin_metadata_html(plugin["config"])), - "fields": {}, - "help_texts": {}, - }, - ) - - sections.append( - { - "name": "config.json", - "description": mark_safe(render_highlighted_json_block(plugin["config"])), - "fields": {}, - "help_texts": {}, - }, - ) - - config_properties = plugin["config"].get("properties", {}) - if config_properties: - sections.append( - { - "name": "Config Properties", - "description": mark_safe(render_config_properties_html(config_properties, machine_admin_url)), - "fields": {}, - "help_texts": {}, - }, - ) - - return ItemContext( - slug=key, - title=plugin["name"], - data=sections, - ) + plugin_name = key.removeprefix("builtin.").removeprefix("user.") + return HttpResponseRedirect(get_plugin_docs_url(plugin_name)) diff --git a/archivebox/templates/admin/actions.html b/archivebox/templates/admin/actions.html index 9015f8b2..2638fe96 100644 --- a/archivebox/templates/admin/actions.html +++ b/archivebox/templates/admin/actions.html @@ -24,11 +24,10 @@ selected - {% if cl.opts.model_name == 'snapshot' %} {% if cl.full_result_count and cl.full_result_count != cl.result_count %} {{ cl.result_count|intcomma }} - / + filtered / {{ cl.full_result_count|intcomma }} total {% else %} @@ -36,7 +35,6 @@ total {% endif %} - {% endif %} {% if cl.result_count != cl.result_list|length %}
+{% endif %} +{% endblock %} + +{% block submit_buttons_top %}{% endblock %} +{% block submit_buttons_bottom %}{% endblock %} diff --git a/archivebox/templates/admin/auth/user/change_form.html b/archivebox/templates/admin/auth/user/change_form.html index b825a830..ca20d1bb 100644 --- a/archivebox/templates/admin/auth/user/change_form.html +++ b/archivebox/templates/admin/auth/user/change_form.html @@ -1,26 +1,17 @@ -{% extends "admin/change_form.html" %} +{% extends "admin/archivebox_change_form.html" %} {% load core_tags %} -{% block extrastyle %} -{{ block.super }} +{% block extrastyle %}{{ block.super }} {% endblock %} -{% block object-tools-items %} +{% block toolbar_extras %} {% if original %} {% api_token as api_token %} -
  • - - RSS +
  • +
    {% endif %} -{{ block.super }} {% endblock %} diff --git a/archivebox/templates/admin/base.html b/archivebox/templates/admin/base.html index 16ea008b..707c639e 100644 --- a/archivebox/templates/admin/base.html +++ b/archivebox/templates/admin/base.html @@ -65,6 +65,8 @@ flex-wrap: wrap; gap: 20px; align-items: stretch; + width: 100%; + box-sizing: border-box; } /* Each fieldset becomes a card */ @@ -355,6 +357,40 @@ background: transparent !important; } + #content-main form fieldset.actions-card, + #content form fieldset.actions-card, + #content-main form fieldset:has(.field-snapshot_summary), + #content form fieldset:has(.field-snapshot_summary) { + flex: 1 1 calc(50% - 10px) !important; + max-width: calc(50% - 10px) !important; + min-width: min(520px, 100%); + } + + .archivebox-snapshot-summary { + display: flex; + gap: 16px; + align-items: flex-start; + min-width: 0; + } + + .archivebox-snapshot-summary-preview { + display: block; + flex: 0 1 220px; + width: 220px; + min-width: 140px; + } + + .archivebox-snapshot-summary-meta { + flex: 1 1 180px; + min-width: 160px; + } + + .archivebox-snapshot-summary-path { + display: inline; + overflow-wrap: anywhere; + word-break: normal; + } + /* Readonly fields styling */ #content-main form fieldset .readonly, #content form fieldset .readonly { @@ -605,26 +641,78 @@ } /* Responsive: 2 columns on medium screens */ - @media (max-width: 1400px) { + @media (max-width: 1500px) { #content-main form fieldset, #content form fieldset { max-width: calc(50% - 10px); flex: 1 1 320px; } + + #content-main form fieldset.actions-card, + #content form fieldset.actions-card, + #content-main form fieldset:has(.field-snapshot_summary), + #content form fieldset:has(.field-snapshot_summary) { + flex-basis: 520px; + min-width: min(520px, 100%); + } } /* Responsive: stack on smaller screens */ @media (max-width: 900px) { + body.change-form #content, + body.change-form #content-main, + body.change-form #content-main > form, + body.change-form #content-main > form > div { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + } + #content-main form fieldset, #content form fieldset { flex: 1 1 100%; max-width: 100%; - min-width: auto; + min-width: 0; + width: 100%; + } + + #content-main form fieldset fieldset, + #content form fieldset fieldset, + #content-main form fieldset .flex-container, + #content form fieldset .flex-container, + #content-main form .module fieldset, + #content form .module fieldset { + width: 100% !important; + max-width: 100% !important; + } + + #content-main form fieldset.actions-card, + #content form fieldset.actions-card, + #content-main form fieldset:has(.field-snapshot_summary), + #content form fieldset:has(.field-snapshot_summary) { + flex-basis: 100% !important; + max-width: 100% !important; + min-width: 0; } #content { padding: 16px; } + + .archivebox-snapshot-summary { + flex-wrap: wrap; + } + + .archivebox-snapshot-summary-preview { + flex-basis: 100%; + width: 100%; + max-width: 320px; + } + + .archivebox-snapshot-summary-meta { + flex-basis: 100%; + } } /* Module content padding */ @@ -1693,7 +1781,7 @@

    - ArchiveBox + ArchiveBox

    diff --git a/archivebox/templates/admin/change_list_panel.html b/archivebox/templates/admin/change_list_panel.html index edea90a5..caad164a 100644 --- a/archivebox/templates/admin/change_list_panel.html +++ b/archivebox/templates/admin/change_list_panel.html @@ -63,7 +63,7 @@ {% if cl.has_filters and not embedded_changelist %} diff --git a/archivebox/templates/core/snapshot.html b/archivebox/templates/core/snapshot.html index 72476798..d5c06afe 100644 --- a/archivebox/templates/core/snapshot.html +++ b/archivebox/templates/core/snapshot.html @@ -39,6 +39,8 @@ background-color: #aa1e55; order: 1; flex: 0 0 auto; + container-type: inline-size; + container-name: snapshot-header; } small { font-weight: 200; @@ -254,6 +256,14 @@ align-items: center; gap: 6px; } + .header-mobile-badges { + display: none; + } + .badge-label, + .badge-mobile-text, + .header-date-label { + display: none; + } .header-year-badges { display: flex; flex-wrap: wrap; @@ -314,7 +324,7 @@ margin-top: -13px; margin-left: 4px; } - @media(max-width: 900px) { + @container snapshot-header (max-width: 900px) { .header-top .header-nav { grid-template-columns: 1fr; gap: 8px; @@ -331,12 +341,49 @@ margin-left: 0; } } - @media(max-width: 600px) { + @container snapshot-header (max-width: 900px) { .header-top { + padding: 10px 12px 12px; font-size: 14px; + text-align: left; + } + .header-top .header-nav { + gap: 10px; + } + .header-top .header-left { + order: 1; + padding-bottom: 2px; + } + .header-top .header-main { + order: 2; + gap: 8px; + } + .header-top .header-meta { + order: 3; + gap: 8px; + } + .header-top .header-right { + order: 4; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + padding: 0 !important; + } + .header-archivebox { + color: #fff !important; + font-size: 16px; + font-weight: 600; + } + .header-archivebox img { + width: 28px; + height: 28px; + margin: 0; } .header-top .header-url { - font-size: 16px; + font-size: 15px; + background: rgba(0, 0, 0, 0.14); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 7px; } .header-title-text, .header-top .header-url a.header-url-text { @@ -346,14 +393,357 @@ -webkit-line-clamp: 2; -webkit-box-orient: vertical; } - .header-badges, - .header-tags, - .header-year-badges { + .header-top .header-url a.header-url-text { + padding: 7px 9px; + line-height: 1.35; + } + .header-title-line { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 28px; + gap: 7px; + align-items: center; + color: #fff; + } + .header-title-text { + grid-column: 2; + grid-row: 1; + color: #fff; + font-size: 14px; + font-weight: 500; + line-height: 1.35; + } + .header-top .favicon { + grid-column: 1; + grid-row: 1; + width: 22px; + height: 22px; + margin: 0; + } + .header-tags { + grid-column: 1 / -1; + grid-row: 2; + width: 100%; justify-content: flex-start; } + .header-tags .tag-pill { + padding: 4px 8px; + font-size: 11px; + background: rgba(255, 255, 255, 0.15); + color: #fff; + } .header-toggle { - font-size: 46px; - vertical-align: -6px; + grid-column: 3; + grid-row: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: 0; + border-radius: 6px; + background: rgba(0, 0, 0, 0.14); + color: #fff !important; + font-size: 24px; + line-height: 1; + } + .header-badges { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr)); + width: 100%; + gap: 6px; + } + header .header-badges .badge { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + min-height: 34px; + margin: 0; + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, 0.16); + font-family: inherit; + font-size: 12px; + font-weight: 600; + } + .badge-label, + .header-date-label { + display: inline; + margin-right: 6px; + color: currentColor; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + opacity: 0.72; + text-transform: uppercase; + } + .badge-desktop-icon { + display: none; + } + .badge-mobile-text { + display: inline; + } + .header-year-badges { + width: 100%; + margin: 0; + padding: 1px 0 0; + justify-content: flex-start; + } + .header-year-badges::before { + content: "Years"; + color: rgba(255, 255, 255, 0.7); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + header .header-year-badges .badge { + margin: 0; + padding: 4px 7px; + font-size: 11px; + } + .header-right .header-date, + .header-right .snapshot-variants { + display: block; + width: 100%; + min-width: 0; + white-space: normal; + } + .header-right .snapshot-date-summary, + .header-right > .header-date { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 34px; + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + background: rgba(0, 0, 0, 0.12); + color: #fff !important; + gap: 7px; + } + .header-right br { + display: none; + } + header .external-links { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + width: 100%; + padding: 0; + color: #fff; + font-size: 12px; + text-align: left; + overflow: visible; + } + header .external-links a { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 6px 9px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + background: rgba(0, 0, 0, 0.12); + color: #fff; + } + .external-links-separator { + display: none; + } + .snapshot-variants-list { + right: auto; + left: 0; + width: min(360px, calc(100vw - 24px)); + min-width: 0; + } + } + @container snapshot-header (max-width: 480px) { + .header-top { + padding: 6px 8px 7px; + font-size: 12px; + } + .header-top .header-nav { + grid-template-columns: 22px minmax(0, 1fr); + gap: 6px; + } + .header-top .header-left { + grid-column: 1; + grid-row: 1; + min-height: 0; + padding-bottom: 0; + line-height: 1 !important; + } + .header-top .header-main { + display: contents; + } + .header-top .header-meta { + grid-column: 1 / -1; + grid-row: 3; + gap: 4px; + } + .header-top .header-right { + grid-column: 1 / -1; + grid-row: 4; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 4px; + } + .header-archivebox { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + overflow: hidden; + font-size: 0; + line-height: 1; + } + .header-archivebox img { + display: block; + width: auto; + height: auto; + max-width: 20px; + max-height: 20px; + margin: 0; + object-fit: contain; + } + .header-top .header-url { + grid-column: 2; + grid-row: 1; + font-size: 13px; + } + .header-top .header-url a.header-url-text { + display: block; + padding: 5px 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .header-title-line { + grid-column: 1 / -1; + grid-row: 2; + grid-template-columns: 22px minmax(0, 1fr) 24px; + gap: 5px; + } + .header-top .favicon { + width: 18px; + height: 18px; + justify-self: center; + } + .header-title-text { + display: block; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .header-toggle { + width: 24px; + height: 24px; + font-size: 20px; + } + .header-tags { + gap: 3px; + } + .header-tags .tag-pill { + padding: 2px 6px; + font-size: 10px; + } + .header-badges { + display: none; + } + .header-mobile-badges { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + width: 100%; + } + .header-mobile-badge { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + min-height: 28px; + padding: 4px 7px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(255, 255, 255, 0.14); + color: #fff !important; + font-size: 11px; + font-weight: 600; + line-height: 1; + } + .header-mobile-badge:last-child { + background: rgba(212, 237, 218, 0.96); + color: #315d3b !important; + } + .header-mobile-badge-separator { + margin: 0 5px; + opacity: 0.45; + } + .badge-label, + .header-date-label, + .header-year-badges::before { + font-size: 9px; + } + .header-year-badges { + display: none; + } + .header-right .snapshot-date-summary, + .header-right > .header-date { + height: 100%; + min-height: 32px; + padding: 4px 6px; + gap: 5px; + font-size: 11px; + font-family: inherit; + font-weight: 600; + line-height: 1; + } + .header-right .snapshot-date-summary > * { + display: inline-flex; + align-items: center; + height: 16px; + margin: 0; + font-family: inherit; + font-size: 11px; + font-weight: 600; + line-height: 1; + letter-spacing: 0; + text-transform: none; + opacity: 1; + } + .header-right > .snapshot-variants, + .header-right > .header-date { + grid-column: 2; + grid-row: 1; + height: 100%; + } + .header-right .snapshot-count-badge { + min-width: 16px; + height: 16px; + padding: 0 4px; + font-size: 9px; + } + header .external-links { + grid-column: 1; + grid-row: 1; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: stretch; + gap: 4px; + font-size: 11px; + flex-wrap: nowrap; + height: 100%; + } + header .external-links a { + justify-content: center; + height: 100%; + min-height: 32px; + font-family: inherit; + font-size: 11px; + font-weight: 600; + line-height: 1; + padding: 4px 7px; } } @@ -666,30 +1056,14 @@ object-position: top center; } .thumb-grid { - display: block; - column-gap: 6px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(clamp(180px, 14vw, 250px), 1fr)); + gap: 6px; align-content: start; width: 100%; max-width: 100%; margin-left: 0; padding: 0 6px; - column-fill: balance; - column-count: 2; - } - @media (min-width: 720px) { - .thumb-grid { column-count: 3; } - } - @media (min-width: 1024px) { - .thumb-grid { column-count: 4; } - } - @media (min-width: 1280px) { - .thumb-grid { column-count: 5; } - } - @media (min-width: 1600px) { - .thumb-grid { column-count: 6; } - } - @media (min-width: 1920px) { - .thumb-grid { column-count: 7; } } .thumb-card { box-shadow: 2px 2px 7px 0px rgba(0, 0, 0, 0.1); @@ -718,8 +1092,13 @@ contain-intrinsic-size: 46px; } .thumb-card .thumb-body { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; font-size: 14px; - padding: 3px 8px; + min-height: 30px; + padding: 4px 6px 4px 8px; line-height: 1.2; word-wrap: break-word; overflow: hidden; @@ -730,22 +1109,37 @@ position: relative; } .thumb-actions { - position: absolute; - top: 2px; - right: 6px; + position: static; + grid-column: 2; + grid-row: 1; display: flex; - gap: 6px; + align-items: center; + gap: 3px; font-size: 12px; line-height: 1; - opacity: 0.7; + opacity: 0.8; } .thumb-actions a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + background: #e7ebef; text-decoration: none; color: inherit; } .thumb-actions a:hover { + background: #d9e0e7; opacity: 1; } + .thumb-card .thumb-body > a:not(.thumb-actions a), + .thumb-card .thumb-body > h4 { + grid-column: 1; + grid-row: 1; + min-width: 0; + } .thumb-card .thumb-body h4 { font-size: 0.8em; text-transform: uppercase; @@ -839,9 +1233,15 @@ flex: 0 0 auto; } .thumb-card:has([data-compact]) .thumb-body { - padding: 2px 6px; + min-height: 22px; + padding: 2px 4px 2px 6px; font-size: 12px; - max-height: 20px; + max-height: 24px; + } + .thumb-card:has([data-compact]) .thumb-actions a { + width: 18px; + height: 18px; + border-radius: 4px; } .thumb-card:has([data-compact]) .thumb-body h4 { font-size: 0.9em; @@ -1175,27 +1575,45 @@
    - {{num_outputs}} - {% if num_failures %} - + {{num_failures}} errors - {% endif %} + Outputs + + {{num_outputs}} + {% if num_failures %} + + {{num_failures}} errors + {% endif %} +
    + {% if related_years %}
    {% for entry in related_years %} @@ -1227,7 +1645,8 @@ {{ related_snapshots|length }} - {{oldest_archive_date|default:downloaded_datestr|default:bookmarked_date}} + Captures + {{oldest_archive_date|default:downloaded_datestr|default:bookmarked_date|slice:":10"}}
    @@ -1240,13 +1659,14 @@ {% else %} - {{oldest_archive_date|default:downloaded_datestr|default:bookmarked_date}} + Captures + {{oldest_archive_date|default:downloaded_datestr|default:bookmarked_date|slice:":10"}} {% endif %}