diff --git a/archivebox/api/v1_cli.py b/archivebox/api/v1_cli.py
index 2ad48699..b1630b74 100644
--- a/archivebox/api/v1_cli.py
+++ b/archivebox/api/v1_cli.py
@@ -66,6 +66,8 @@ class AddCommandSchema(Schema):
parser: str = "auto"
plugins: str = ""
only_new: bool | None = None
+ update: bool = False
+ overwrite: bool = False
index_only: bool = False
@@ -90,6 +92,8 @@ class ScheduleCommandSchema(Schema):
tag: str = ""
depth: int = 0
only_new: bool | None = None
+ update: bool = False
+ overwrite: bool = False
clear: bool = False
@@ -120,6 +124,8 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
+ if args.update or args.overwrite:
+ config_overrides["ONLY_NEW"] = False
crawl, snapshots = add(
urls=args.urls,
snapshot_ids=args.snapshot_ids,
@@ -189,6 +195,8 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
+ if args.update or args.overwrite:
+ config_overrides["ONLY_NEW"] = False
result = schedule(
import_path=args.import_path,
add=args.add,
diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py
index 32c1006b..e0f602b9 100644
--- a/archivebox/api/v1_crawls.py
+++ b/archivebox/api/v1_crawls.py
@@ -122,7 +122,8 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
tags = normalize_tag_list(data.tags, data.tags_str)
config = dict(data.config or {})
- config.setdefault("PERMISSIONS", str(get_config(user=request.user).PERMISSIONS))
+ request_user = request.user if request.user.is_authenticated else None
+ config.setdefault("PERMISSIONS", str(get_config(user=request_user).PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(urls),
max_depth=data.max_depth,
diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py
index 1db8e93f..016415a3 100644
--- a/archivebox/cli/archivebox_add.py
+++ b/archivebox/cli/archivebox_add.py
@@ -185,7 +185,7 @@ def add(
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
created_by_id=created_by_id,
status=Crawl.StatusChoices.QUEUED,
- retry_at=None if index_only else timezone.now(),
+ retry_at=None if (index_only or bg) else timezone.now(),
config=crawl_config,
)
@@ -198,15 +198,8 @@ def add(
# Discovered URLs become child Snapshots (depth+1)
if index_only:
- # ``--index-only`` means "add the URLs to the index without archiving
- # them now". That only holds if we actually materialize the Snapshot
- # rows here — otherwise the CLI returns success with nothing in the
- # index, which broke ``test_add_url_after_init`` & friends. Create
- # the Snapshots synchronously (the same step the runner would do)
- # but skip starting any worker so extractors don't run.
- crawl.create_snapshots_from_urls()
- print("[yellow]\\[*] Index-only mode - URLs indexed, runner not started[/yellow]")
- return crawl, crawl.snapshot_set.all()
+ print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]")
+ return crawl, crawl.snapshot_set.none()
# 5. Start the crawl runner to process the queue
# The runner will:
@@ -330,6 +323,8 @@ def add(
"Pass --no-only-new to force re-archive of URLs that already exist.",
)
@click.option("--index-only", is_flag=True, help="Just add the URLs to the index without archiving them now")
+@click.option("--overwrite", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)")
+@click.option("--update", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)")
@click.option("--bg", is_flag=True, help="Run archiving in background (queue work and return immediately)")
@click.argument("urls", nargs=-1, type=click.Path())
@docstring(add.__doc__)
@@ -360,7 +355,11 @@ def main(**kwargs):
# Translate --only-new/--no-only-new into a crawl config override.
# add() takes config overrides as a dict; no per-flag kwargs.
+ overwrite = kwargs.pop("overwrite", False)
+ update = kwargs.pop("update", False)
only_new = kwargs.pop("only_new", None)
+ if overwrite or update:
+ only_new = False
if only_new is not None:
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
diff --git a/archivebox/cli/archivebox_init.py b/archivebox/cli/archivebox_init.py
index 957b78ee..87bc05f2 100755
--- a/archivebox/cli/archivebox_init.py
+++ b/archivebox/cli/archivebox_init.py
@@ -73,6 +73,8 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
config.USERS_DIR.mkdir(parents=True, exist_ok=True)
Path(CONSTANTS.LOGS_DIR).mkdir(exist_ok=True)
+ for path in (Path(CONSTANTS.SOURCES_DIR), config.ARCHIVE_DIR, config.USERS_DIR, Path(CONSTANTS.LOGS_DIR)):
+ path.chmod(int(config.OUTPUT_PERMISSIONS, base=8) | 0o111)
print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, DATA_DIR)}...")
diff --git a/archivebox/cli/archivebox_install.py b/archivebox/cli/archivebox_install.py
index 781c7024..6817e345 100755
--- a/archivebox/cli/archivebox_install.py
+++ b/archivebox/cli/archivebox_install.py
@@ -28,6 +28,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
config = get_config()
archive_dir = config.ARCHIVE_DIR
+
+ if dry_run:
+ print("[dim]Dry run - would detect ArchiveBox dependencies and run the abx-dl install flow[/dim]")
+ return
+
if not (os.access(archive_dir, os.R_OK) and archive_dir.is_dir()):
init() # must init full index because we need a db to store Binary entries in
@@ -47,10 +52,6 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
print(f" DATA_DIR will be owned by [blue]{ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}[/blue].")
print()
- if dry_run:
- print("[dim]Dry run - would run the abx-dl install flow[/dim]")
- return
-
# Set up Django
from archivebox.config.django import setup_django
diff --git a/archivebox/config/common.py b/archivebox/config/common.py
index e5bbc79f..adb992db 100644
--- a/archivebox/config/common.py
+++ b/archivebox/config/common.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
__package__ = "archivebox.config"
import json
@@ -13,7 +15,7 @@ from typing import Any, ClassVar, cast
from pathlib import Path
from rich.console import Console
-from pydantic import BaseModel, Field, create_model, field_validator, model_validator
+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
@@ -64,6 +66,18 @@ def permissions_from_legacy_public_flags(raw_config: Mapping[str, object]) -> st
_SENSITIVE_CONFIG_KEY_NEEDLES = ("TOKEN", "SECRET", "API_KEY", "APIKEY", "PASSWORD")
SENSITIVE_CONFIG_VALUE_REDACTED = "********"
+_SCOPE_CRAWL_FROZEN = "crawl_frozen"
+_SCOPE_CRAWL_EXECUTION = "crawl_execution"
+_SCOPE_SERVER = "server"
+
+
+
+def _plugin_sensitive_config_keys() -> set[str]:
+ sensitive_keys: set[str] = set()
+ for prop_key, prop_schema in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS).items():
+ if isinstance(prop_schema, Mapping) and prop_schema.get("x-sensitive"):
+ sensitive_keys.add(str(prop_key))
+ return sensitive_keys
def is_sensitive_config_key(key: str) -> bool:
@@ -77,8 +91,9 @@ def is_sensitive_config_key(key: str) -> bool:
REST API responses, and any future surface that round-trips raw config
values all agree on which keys to redact.
"""
- upper = (key or "").upper()
- return any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES)
+ key = str(key or "")
+ upper = key.upper()
+ return key in _plugin_sensitive_config_keys() or any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES)
def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -103,6 +118,34 @@ def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
return redacted
+
+def normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | None) -> dict[str, Any]:
+ """Return a JSON-safe config dict suitable for storage or event payloads."""
+ if config is None:
+ return {}
+ if isinstance(config, BaseConfigSet):
+ config = config.model_dump(mode="json")
+ elif isinstance(config, str):
+ config = json.loads(config)
+ else:
+ config = dict(config)
+ return {key: value for key, value in json.loads(json.dumps(config, default=str)).items() if value is not None}
+
+
+def build_crawl_config_snapshot(
+ *,
+ user: Any = None,
+ persona: Any = None,
+ overrides: Mapping[str, Any] | None = None,
+ base_config: ArchiveBoxBaseConfig | Mapping[str, object] | None = None,
+) -> dict[str, Any]:
+ """Build the frozen runtime config stored on Crawl.config at creation time."""
+ effective = get_config(user=user, persona=persona, base_config=base_config, include_machine=False)
+ frozen = effective.for_crawl_frozen()
+ if overrides:
+ frozen = get_config(base_config=frozen, overrides=overrides, include_machine=False).for_crawl_frozen()
+ return frozen
+
def rprint(*args, file=None, **kwargs):
console = _STDERR_CONSOLE if file is sys.stderr else _STDOUT_CONSOLE
console.print(*args, **kwargs)
@@ -110,6 +153,7 @@ def rprint(*args, file=None, **kwargs):
class ShellConfig(BaseConfigSet):
toml_section_header: str = "SHELL_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_CRAWL_EXECUTION)
DEBUG: bool = Field(default="--debug" in sys.argv)
@@ -141,6 +185,7 @@ class ShellConfig(BaseConfigSet):
class StorageConfig(BaseConfigSet):
toml_section_header: str = "STORAGE_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
# ARCHIVE_DIR / USERS_DIR are resolved dynamically via get_config().
ARCHIVE_DIR: Path = Field(default=CONSTANTS.ARCHIVE_DIR)
@@ -150,34 +195,36 @@ class StorageConfig(BaseConfigSet):
# TMP_DIR must be a local, fast, readable/writable dir by archivebox user,
# must be a short path due to unix path length restrictions for socket files (<100 chars)
# must be a local SSD/tmpfs for speed and because bind mounts/network mounts/FUSE dont support unix sockets
- TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR)
+ TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# LIB_DIR must be a local, fast, readable/writable dir by archivebox user,
# must be able to contain executable binaries (up to 5GB size)
# should not be a remote/network/FUSE mount for speed reasons, otherwise extractors will be slow
- LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR)
+ LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# LIB_BIN_DIR is an optional human-facing symlink convenience directory.
# Runtime lookup must use provider-specific paths under LIB_DIR instead.
- LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR)
+ LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# CUSTOM_TEMPLATES_DIR allows users to override default templates
# defaults to DATA_DIR / 'user_templates' but can be configured
CUSTOM_TEMPLATES_DIR: Path = Field(default=CONSTANTS.CUSTOM_TEMPLATES_DIR)
- OUTPUT_PERMISSIONS: str = Field(default="644")
+ OUTPUT_PERMISSIONS: str = Field(default="644", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
ENFORCE_ATOMIC_WRITES: bool = Field(default=True)
ALLOW_NO_UNIX_SOCKETS: bool = Field(default=False, alias="ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS")
class GeneralConfig(BaseConfigSet):
toml_section_header: str = "GENERAL_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
TAG_SEPARATOR_PATTERN: str = Field(default=r"[,]")
class ServerConfig(BaseConfigSet):
toml_section_header: str = "SERVER_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
SERVER_SECURITY_MODES: ClassVar[tuple[str, ...]] = (
"safe-subdomains-fullreplay",
@@ -258,6 +305,7 @@ class ServerConfig(BaseConfigSet):
class DatabaseConfig(BaseConfigSet):
toml_section_header: str = "DATABASE_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
DATABASE_NAME: str = Field(default=str(CONSTANTS.DATABASE_FILE), alias="ARCHIVEBOX_DATABASE_NAME")
SQLITE_JOURNAL_MODE: str = Field(
@@ -277,6 +325,7 @@ class DatabaseConfig(BaseConfigSet):
class ArchivingConfig(BaseConfigSet):
toml_section_header: str = "ARCHIVING_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_CRAWL_FROZEN)
PLUGINS: str = Field(
default="",
@@ -284,6 +333,7 @@ class ArchivingConfig(BaseConfigSet):
)
ONLY_NEW: bool = Field(default=True)
+ INDEX_ONLY: bool = Field(default=False)
TIMEOUT: int = Field(default=60)
CRAWL_MAX_URLS: int = Field(default=0)
@@ -397,6 +447,7 @@ def parse_delete_after(value) -> timedelta | None:
class SearchBackendConfig(BaseConfigSet):
toml_section_header: str = "SEARCH_BACKEND_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
SEARCH_BACKEND_ENGINE: str = Field(default="ripgrep")
@@ -473,12 +524,93 @@ class ArchiveBoxBaseConfig(
populate_by_name=True,
)
- DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR)
- ABX_RUNTIME: str = Field(default="archivebox")
- CRAWL_DIR: Path | None = Field(default=None)
- SNAP_DIR: Path | None = Field(default=None)
+ DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
+ ABX_RUNTIME: str = Field(default="archivebox", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
+ CRAWL_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
+ SNAP_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
computed_config_keys: ClassVar[tuple[str, ...]] = COMPUTED_CONFIG_KEYS
+ @classmethod
+ def _core_config_classes(cls) -> tuple[type[BaseConfigSet], ...]:
+ return (
+ ShellConfig,
+ StorageConfig,
+ GeneralConfig,
+ ServerConfig,
+ DatabaseConfig,
+ ArchivingConfig,
+ SearchBackendConfig,
+ LDAPConfig,
+ )
+
+ @classmethod
+ def _core_field_scope(cls, key: str) -> str | None:
+ if key == "toml_section_header":
+ return _SCOPE_SERVER
+ for config_cls in cls._core_config_classes():
+ field = config_cls.model_fields.get(key)
+ if field is None:
+ continue
+ default_scope = str(config_cls.__private_attributes__["_scope"].default)
+ extra = field.json_schema_extra
+ if isinstance(extra, dict) and "scope" in extra:
+ return str(extra["scope"])
+ return default_scope
+ if key in ArchiveBoxBaseConfig.model_fields:
+ field = ArchiveBoxBaseConfig.model_fields[key]
+ extra = field.json_schema_extra
+ if isinstance(extra, dict) and "scope" in extra:
+ return str(extra["scope"])
+ return _SCOPE_SERVER
+ return None
+
+ @classmethod
+ def _plugin_field_scope(cls, key: str) -> str | None:
+ for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
+ properties = schema.get("properties") if isinstance(schema, dict) else None
+ if not isinstance(properties, dict) or key not in properties:
+ continue
+ prop_schema = properties.get(key) or {}
+ if isinstance(prop_schema, Mapping) and prop_schema.get("x-scope"):
+ return str(prop_schema["x-scope"])
+ if str(plugin_name).startswith("search_backend_"):
+ return _SCOPE_SERVER
+ upper = key.upper()
+ if (
+ upper == "PATH"
+ or upper.endswith("_PATH")
+ or upper.endswith("_DIR")
+ or upper.endswith("_BINARY")
+ or upper.endswith("_CACHE")
+ or upper.endswith("_COVERAGE")
+ or upper.endswith("_PYTHON")
+ ):
+ return _SCOPE_CRAWL_EXECUTION
+ return _SCOPE_CRAWL_FROZEN
+ return None
+
+ @classmethod
+ def scope_for_key(cls, key: str) -> str:
+ return cls._core_field_scope(key) or cls._plugin_field_scope(key) or _SCOPE_SERVER
+
+ def _scoped_config(self, *, include_execution: bool) -> dict[str, Any]:
+ allowed_scopes = {_SCOPE_CRAWL_FROZEN}
+ if include_execution:
+ allowed_scopes.add(_SCOPE_CRAWL_EXECUTION)
+ return {
+ key: value
+ for key, value in normalize_runtime_config(self).items()
+ if type(self).scope_for_key(key) in allowed_scopes
+ }
+
+ def for_crawl_execution(self) -> dict[str, Any]:
+ """Config safe to pass to crawl/snapshot hook execution."""
+ return self._scoped_config(include_execution=True)
+
+ def for_crawl_frozen(self) -> dict[str, Any]:
+ """Config safe to persist permanently on Crawl.config."""
+ return self._scoped_config(include_execution=False)
+
@model_validator(mode="after")
def resolve_runtime_paths(self):
self.DATA_DIR = self.DATA_DIR.expanduser().resolve()
@@ -552,12 +684,12 @@ def get_config(
1. Explicit overrides
2. Per-ArchiveResult config
3. Per-snapshot config and output path
- 4. Per-crawl config and output path
- 5. Per-user config
- 6. Per-persona derived config
- 7. Current machine derived config
- 8. Environment variables
- 9. Config file (ArchiveBox.conf)
+ 4. Frozen per-crawl config and output path
+ 5. Per-user config (only when resolving outside a crawl)
+ 6. Per-persona derived config (only when resolving outside a crawl)
+ 7. Current machine derived config (only when resolving outside a crawl)
+ 8. Environment variables (only when resolving outside a crawl)
+ 9. Config file (ArchiveBox.conf, only when resolving outside a crawl)
10. Plugin schema defaults
11. Core config defaults
"""
@@ -567,7 +699,9 @@ def get_config(
if crawl is None and snapshot is not None:
crawl = snapshot.crawl
- if include_machine and machine is None:
+ crawl_config_base = crawl is not None and base_config is None
+
+ if include_machine and machine is None and not crawl_config_base:
try:
from django.apps import apps
@@ -578,15 +712,19 @@ def get_config(
except Exception:
machine = None
- if persona is None and crawl is not None:
+ if persona is None and crawl is not None and not crawl_config_base:
persona = crawl.resolve_persona()
config_data: ConfigPayload = dict(defaults or {})
- if base_config is not None:
+ base_config_payload: ConfigPayload = {}
+ if crawl_config_base:
+ config_data.update(dict(crawl.config or {}))
+ elif base_config is not None:
if isinstance(base_config, ArchiveBoxBaseConfig):
- config_data.update(base_config.model_dump(mode="json"))
+ base_config_payload.update(base_config.model_dump(mode="json"))
else:
- config_data.update(dict(base_config))
+ base_config_payload.update(dict(base_config))
+ config_data.update(base_config_payload)
else:
config_data.update(ArchiveBoxConfig().model_dump(mode="json"))
legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ})
@@ -595,19 +733,16 @@ def get_config(
scope_overrides: ConfigPayload = {}
- if include_machine and machine is not None and machine.config:
- from archivebox.machine.models import _sanitize_machine_config
+ if not crawl_config_base:
+ if include_machine and machine is not None and machine.config:
+ from archivebox.machine.models import _sanitize_machine_config
- scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")))
+ scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")))
- if persona is not None:
- scope_overrides.update(persona.get_derived_config())
+ if persona is not None:
+ scope_overrides.update(persona.get_derived_config())
- user_config = getattr(user, "config", None)
- if user_config:
- scope_overrides.update(user_config)
-
- if crawl is not None and crawl.config:
+ if crawl is not None and crawl.config and not crawl_config_base:
scope_overrides.update(crawl.config)
if crawl is not None:
@@ -638,13 +773,20 @@ def get_config(
plugin_name: schema.get("properties", {}) for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)
}
plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()}
+ plugin_user_config = _plugin_user_config(scope_overrides)
+ if not crawl_config_base:
+ plugin_user_config = {**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **plugin_user_config}
plugin_sections = resolve_plugin_configs(
plugin_schemas,
global_config=plugin_global_config,
- user_config={**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **_plugin_user_config(scope_overrides)},
+ user_config=plugin_user_config,
)
for plugin_config in plugin_sections.values():
config_data.update(plugin_config)
+ if base_config_payload:
+ config_data.update({key: value for key, value in base_config_payload.items() if key in _archivebox_config_input_names()})
+ if crawl_config_base:
+ config_data.update(dict(crawl.config or {}))
config_data.update(archivebox_scope_overrides)
config_data["ABX_RUNTIME"] = "archivebox"
diff --git a/archivebox/config/ldap.py b/archivebox/config/ldap.py
index 40218c5e..16d84ad2 100644
--- a/archivebox/config/ldap.py
+++ b/archivebox/config/ldap.py
@@ -1,6 +1,6 @@
__package__ = "archivebox.config"
-from pydantic import Field
+from pydantic import Field, PrivateAttr
from archivebox.config.configset import BaseConfigSet
@@ -14,6 +14,7 @@ class LDAPConfig(BaseConfigSet):
"""
toml_section_header: str = "LDAP_CONFIG"
+ _scope: str = PrivateAttr(default="server")
LDAP_ENABLED: bool = Field(default=False)
LDAP_SERVER_URI: str | None = Field(default=None)
diff --git a/archivebox/config/views.py b/archivebox/config/views.py
index 9ef6ea50..0fb7bf27 100644
--- a/archivebox/config/views.py
+++ b/archivebox/config/views.py
@@ -109,7 +109,7 @@ def get_machine_admin_url() -> str | None:
from archivebox.machine.models import Machine
machine = Machine.current()
- return getattr(machine, "admin_change_url", None) or f"/admin/machine/machine/{machine.id.hex}/change/"
+ return getattr(machine, "admin_change_url", None) or f"/admin/machine/machine/{machine.id}/change/"
except Exception:
return None
diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py
index c452b3e4..7602d21a 100644
--- a/archivebox/core/forms.py
+++ b/archivebox/core/forms.py
@@ -453,9 +453,9 @@ class PluginConfigFormMixin:
if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list):
raw_value = self.data.getlist(input_name)
- from archivebox.config.common import is_sensitive_config_key
+ from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, is_sensitive_config_key
- if (prop_schema.get("x-sensitive") or is_sensitive_config_key(config_key)) and raw_value == "":
+ if (prop_schema.get("x-sensitive") or is_sensitive_config_key(config_key)) and raw_value in ("", SENSITIVE_CONFIG_VALUE_REDACTED):
continue
try:
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index 2af3a4ed..0905cf37 100755
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -636,12 +636,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@classmethod
def missing_delete_at_candidates(cls):
- from archivebox.personas.models import Persona
-
- persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True)
- return cls.objects.filter(delete_at__isnull=True).filter(
- Q(config__has_key="DELETE_AFTER") | Q(crawl__config__has_key="DELETE_AFTER") | Q(crawl__persona_id__in=persona_ids),
- )
+ return cls.objects.filter(delete_at__isnull=True).filter(Q(config__has_key="DELETE_AFTER") | Q(crawl__config__has_key="DELETE_AFTER"))
@classmethod
def is_archivebox_internal_url(cls, url: str) -> bool:
@@ -3498,14 +3493,10 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
@classmethod
def missing_delete_at_candidates(cls):
- from archivebox.personas.models import Persona
-
- persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True)
return cls.objects.filter(delete_at__isnull=True).filter(
Q(config__has_key="DELETE_AFTER")
| Q(snapshot__config__has_key="DELETE_AFTER")
- | Q(snapshot__crawl__config__has_key="DELETE_AFTER")
- | Q(snapshot__crawl__persona_id__in=persona_ids),
+ | Q(snapshot__crawl__config__has_key="DELETE_AFTER"),
)
@property
diff --git a/archivebox/core/settings_logging.py b/archivebox/core/settings_logging.py
index 957d2f2a..5613451f 100644
--- a/archivebox/core/settings_logging.py
+++ b/archivebox/core/settings_logging.py
@@ -7,6 +7,7 @@ import logging
from archivebox.config import CONSTANTS
+from archivebox.misc.logging import STDERR
IGNORABLE_URL_PATTERNS = [
@@ -182,6 +183,7 @@ SETTINGS_LOGGING = {
"level": "DEBUG",
"markup": False,
"rich_tracebacks": False, # Use standard Python tracebacks (no frame/box)
+ "console": STDERR,
"filters": ["noisyrequestsfilter", "daphneclosetimeout", "asynciocancelledshield", "stripansi"],
},
"logfile": {
diff --git a/archivebox/core/views.py b/archivebox/core/views.py
index 0bb98752..4b9c26b9 100644
--- a/archivebox/core/views.py
+++ b/archivebox/core/views.py
@@ -33,7 +33,7 @@ from admin_data_views.utils import render_with_table_view, render_with_item_view
from abx_dl.events import PROCESS_EXIT_SKIPPED
from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, VERSION
-from archivebox.config.common import get_config, get_all_configs
+from archivebox.config.common import get_config, get_all_configs, is_sensitive_config_key
from archivebox.config.configset import BaseConfigSet
from archivebox.misc.paginators import CountlessPaginator
from archivebox.misc.util import (
@@ -1177,8 +1177,6 @@ class AddView(UserPassesTestMixin, FormView):
required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip()
can_override_crawl_config = self._can_override_crawl_config()
plugin_configs = discover_plugin_configs() if can_override_crawl_config else {}
- from archivebox.config.common import is_sensitive_config_key
-
sensitive_keys = {
str(config_key)
for schema in plugin_configs.values()
@@ -1304,7 +1302,8 @@ class AddView(UserPassesTestMixin, FormView):
config = {}
if plugins:
config["PLUGINS"] = plugins
- effective_config = get_config(persona=persona, user=self.request.user) if persona else get_config(user=self.request.user)
+ request_user = self.request.user if self.request.user.is_authenticated else None
+ effective_config = get_config(persona=persona, user=request_user) if persona else get_config(user=request_user)
if crawl_max_concurrent_snapshots != int(effective_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS):
config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots
if delete_after != str(effective_config.DELETE_AFTER):
@@ -1352,6 +1351,7 @@ class AddView(UserPassesTestMixin, FormView):
template=crawl,
schedule=schedule,
is_enabled=True,
+ config=config,
label=crawl.label,
notes=f"Auto-created from add page. {notes}".strip(),
created_by_id=created_by_id,
@@ -2508,13 +2508,6 @@ def find_config_type(key: str) -> str:
return "str"
-def key_is_safe(key: str) -> bool:
- for term in ("key", "password", "secret", "token"):
- if term in key.lower():
- return False
- return True
-
-
def find_config_source(key: str, merged_config: dict) -> str:
"""Determine where a config value comes from."""
from archivebox.machine.models import Machine
@@ -2604,7 +2597,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
# Use merged config value (includes machine overrides)
actual_value = merged_config.get(key, getattr(section, key, None))
- rows["Value"].append(mark_safe(f"{actual_value}") if key_is_safe(key) else "******** (redacted)")
+ rows["Value"].append(mark_safe(f"{actual_value}") if not is_sensitive_config_key(key) else "******** (redacted)")
# Show where the value comes from
source = find_config_source(key, merged_config)
@@ -2624,7 +2617,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
rows["Section"].append(section) # section.replace('_', ' ').title().replace(' Config', '')
rows["Key"].append(ItemLink(key, key=key))
rows["Type"].append(format_html("{}", getattr(type(CONSTANTS_CONFIG[key]), "__name__", str(CONSTANTS_CONFIG[key]))))
- rows["Value"].append(format_html("{}", CONSTANTS_CONFIG[key]) if key_is_safe(key) else "******** (redacted)")
+ rows["Value"].append(format_html("{}", CONSTANTS_CONFIG[key]) if not is_sensitive_config_key(key) else "******** (redacted)")
rows["Source"].append(mark_safe('Constant'))
rows["Default"].append(
mark_safe(
@@ -2655,16 +2648,16 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
# Environment variable
if key in os.environ:
- sources_info.append(("Environment", os.environ[key] if key_is_safe(key) else "********", "blue"))
+ sources_info.append(("Environment", os.environ[key] if not is_sensitive_config_key(key) else "********", "blue"))
# Machine config
machine = None
machine_admin_url = None
try:
machine = Machine.current()
- machine_admin_url = f"/admin/machine/machine/{machine.id.hex}/change/"
+ machine_admin_url = f"/admin/machine/machine/{machine.id}/change/"
if machine.config and key in machine.config:
- sources_info.append(("Machine", machine.config[key] if key_is_safe(key) else "********", "purple"))
+ sources_info.append(("Machine", machine.config[key] if not is_sensitive_config_key(key) else "********", "purple"))
except Exception:
pass
@@ -2680,8 +2673,12 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
sources_info.append(("Default", default_val, "gray"))
# Final computed value
- final_value = merged_config.get(key, CONFIGS.get(key, None))
- if not key_is_safe(key):
+ config_source = find_config_source(key, merged_config)
+ if config_source == "Environment":
+ final_value = get_config(include_machine=False).model_dump(mode="json").get(key, CONFIGS.get(key, None))
+ else:
+ final_value = merged_config.get(key, CONFIGS.get(key, None))
+ if is_sensitive_config_key(key):
final_value = "********"
# Build sources display
@@ -2714,7 +2711,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
"Key": key,
"Type": find_config_type(key),
"Value": final_value,
- "Currently read from": find_config_source(key, merged_config),
+ "Currently read from": config_source,
},
"help_texts": {
"Key": mark_safe(f"""
@@ -2731,7 +2728,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
"Value": mark_safe(f'''
{
'Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)
'
- if not key_is_safe(key)
+ if is_sensitive_config_key(key)
else ""
}
archivebox config --set {key}="{
val.strip("'")
if (val := find_config_default(key))
- else (str(final_value if key_is_safe(key) else "********")).strip("'")
+ else (str(final_value if not is_sensitive_config_key(key) else "********")).strip("'")
}"
'''),
"Currently read from": mark_safe(f"""
- The value shown in the "Value" field comes from the {find_config_source(key, merged_config)} source.
+ The value shown in the "Value" field comes from the {config_source} source.