Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae7d0197d | ||
|
|
e6c774f637 | ||
|
|
d7edaa04d6 | ||
|
|
c28e3a9810 | ||
|
|
bf8b2f0a90 | ||
|
|
3a542f9b34 | ||
|
|
915d9420ff | ||
|
|
c076737e4a | ||
|
|
c8eaeb531b | ||
|
|
78fb23b47f | ||
|
|
40a2bc6f4c | ||
|
|
4d4a4a96b6 | ||
|
|
4377c208d8 | ||
|
|
a729790b8e | ||
|
|
322b4cbe3f | ||
|
|
8b57085827 | ||
|
|
f3c8460767 | ||
|
|
19a2e44c5b | ||
|
|
e63f24dc71 | ||
|
|
0064e31ebf | ||
|
|
c17c08c09f | ||
|
|
fe4e0c7639 | ||
|
|
88d46a302f | ||
|
|
d1aec6ed3b | ||
|
|
cb5c822eb4 | ||
|
|
a25a33eba1 | ||
|
|
5ac48301d6 | ||
|
|
6f4b53bfca | ||
|
|
53d5d0ece4 | ||
|
|
311d333b4b | ||
|
|
6f321797c9 | ||
|
|
79df97b99d | ||
|
|
88a92b6548 | ||
|
|
541b3fec87 | ||
|
|
0ddda66ee3 | ||
|
|
b768e1767e | ||
|
|
6bf3ce989d | ||
|
|
24c123e331 | ||
|
|
3596325733 | ||
|
|
8baec62daa | ||
|
|
27d39749bf | ||
|
|
9a6d1b0410 | ||
|
|
3adb214b42 | ||
|
|
d3ee9512e5 | ||
|
|
12deb40daa | ||
|
|
fa5c918c2f | ||
|
|
11c9f8c163 | ||
|
|
18e3580dcd | ||
|
|
6ae56b7366 | ||
|
|
73ec255855 | ||
|
|
faf2109642 | ||
|
|
1dbde4776f | ||
|
|
c19b8de5d6 | ||
|
|
87dbff486d | ||
|
|
a48ce6871b | ||
|
|
8ba658b4dc | ||
|
|
824af25371 | ||
|
|
cfdd1293e9 | ||
|
|
54dae36aec | ||
|
|
e547abbf27 | ||
|
|
22453418fb | ||
|
|
c9e63ccffd | ||
|
|
6b6ac24307 | ||
|
|
6a635d3cd6 | ||
|
|
7e4dd2ac2d | ||
|
|
29cf3fe4ee | ||
|
|
14d43c88f5 | ||
|
|
33436ae646 | ||
|
|
6e0f2a41a3 | ||
|
|
b6921d5e03 | ||
|
|
f6c98b67d7 | ||
|
|
24caacdd1d | ||
|
|
426e23f33c | ||
|
|
e6091cf50e | ||
|
|
77500bfac8 | ||
|
|
2e9be6751e | ||
|
|
bb598b9710 | ||
|
|
283f0afdb5 | ||
|
|
9d5ed1bc42 | ||
|
|
585c951aa7 | ||
|
|
635e318647 | ||
|
|
1da4b79e9d | ||
|
|
e63552ad1d | ||
|
|
dfc0c66fc0 | ||
|
|
cd959844ca | ||
|
|
0920431b53 | ||
|
|
7c51542335 | ||
|
|
2bda2abbf0 | ||
|
|
2f65977b2e | ||
|
|
9b6b95c97c | ||
|
|
2081fe55e4 | ||
|
|
f468b5b471 | ||
|
|
9149ee12bf | ||
|
|
1fd8c8b7c2 | ||
|
|
7e54e3f813 | ||
|
|
d3e3a5fc23 | ||
|
|
303e99258c | ||
|
|
77964bcde9 | ||
|
|
2127d07697 | ||
|
|
1118d5f1e6 | ||
|
|
e38719f15c | ||
|
|
9c29724efa | ||
|
|
3328d9fc6a |
No files matched your search
@@ -13,10 +13,14 @@ __pycache__
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.uv-cache/
|
||||
.cache/
|
||||
.cache*/
|
||||
.github/
|
||||
.pdm-build/
|
||||
.pdm-build/**
|
||||
.pdm-python
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
.git/
|
||||
!.git/
|
||||
.git/*
|
||||
@@ -42,6 +46,7 @@ chromeprofile/
|
||||
chrome_profile/
|
||||
lib/
|
||||
out/
|
||||
tests/out/
|
||||
users/
|
||||
archive/
|
||||
crawls/
|
||||
@@ -63,6 +68,8 @@ dist/
|
||||
brew_dist/
|
||||
deb_dist/
|
||||
pip_dist/
|
||||
cache/
|
||||
caches/
|
||||
assets/
|
||||
docker/
|
||||
website/
|
||||
|
||||
@@ -69,7 +69,7 @@ body:
|
||||
0.8.6
|
||||
ArchiveBox v0.8.6rc0 COMMIT_HASH=721427a BUILD_TIME=2024-10-21 12:57:02 1729515422
|
||||
IN_DOCKER=False IN_QEMU=False ARCH=arm64 OS=Darwin PLATFORM=macOS-15.1-arm64-arm-64bit PYTHON=Cpython (venv)
|
||||
EUID=502:20 UID=502:20 PUID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
|
||||
EUID=502:20 UID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
|
||||
DEBUG=False IS_TTY=True SUDO=False ID=dfa11485:aa78ad45 SEARCH_BACKEND=ripgrep LDAP=False
|
||||
|
||||
Binary Dependencies:
|
||||
|
||||
@@ -71,7 +71,7 @@ body:
|
||||
0.8.6
|
||||
ArchiveBox v0.8.6rc0 COMMIT_HASH=721427a BUILD_TIME=2024-10-21 12:57:02 1729515422
|
||||
IN_DOCKER=False IN_QEMU=False ARCH=arm64 OS=Darwin PLATFORM=macOS-15.1-arm64-arm-64bit PYTHON=Cpython (venv)
|
||||
EUID=502:20 UID=502:20 PUID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
|
||||
EUID=502:20 UID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
|
||||
DEBUG=False IS_TTY=True SUDO=False ID=dfa11485:aa78ad45 SEARCH_BACKEND=ripgrep LDAP=False
|
||||
|
||||
Binary Dependencies:
|
||||
|
||||
+47
-10
@@ -2,18 +2,15 @@
|
||||
|
||||
---
|
||||
|
||||
## Security Information
|
||||
## Security Documentation
|
||||
|
||||
Please see this wiki page for important notices about ArchiveBox security, publishing your archives securely, and the dangers of executing archived JS:
|
||||
|
||||
https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
|
||||
|
||||
Also see this section of the README about important caveats when running ArchiveBox:
|
||||
|
||||
https://github.com/ArchiveBox/ArchiveBox?tab=readme-ov-file#caveats
|
||||
|
||||
You can also read these pages for more information about ArchiveBox's internals, development environment, DB schema, and more:
|
||||
Please start by reading these links to understand our existing security model, known issues, API surfaces, and setup steps:
|
||||
|
||||
- https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
|
||||
- https://github.com/ArchiveBox/ArchiveBox/security/advisories/
|
||||
- https://github.com/ArchiveBox/ArchiveBox/tree/v0.7.4#security-risks-of-viewing-archived-js
|
||||
- https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive#security-concerns
|
||||
- https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-up-Authentication
|
||||
- https://github.com/ArchiveBox/ArchiveBox#archive-layout
|
||||
- https://github.com/ArchiveBox/ArchiveBox#archivebox-development
|
||||
- https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives
|
||||
@@ -32,3 +29,43 @@ We use Github's built-in [Private Reporting](https://docs.github.com/en/code-sec
|
||||
3. Fill out the form to submit the details of the report and it will be securely sent to the maintainers
|
||||
|
||||
You can also contact the maintainers via our public [Zulip Chat Server zulip.archivebox.io](https://zulip.archivebox.io) or [Twitter DMs @ArchiveBoxApp](https://twitter.com/ArchiveBoxApp).
|
||||
|
||||
---
|
||||
|
||||
## CVEs and Report Credits
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Reports that only affect `dev` or beta versions will be converted to normal Github issues and will not be issued CVEs.**
|
||||
> Reporters **will still receive credit in release notes** however.
|
||||
|
||||
> [!TIP]
|
||||
> Reports that affect **stable published versions will be issued CVEs** and published w/ full credit.
|
||||
|
||||
Please read our existing published [security advisories](https://github.com/ArchiveBox/ArchiveBox/security/advisories), [Security Overview Docs](https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview), and [issues](https://github.com/ArchiveBox/ArchiveBox/issues) and avoid creating duplicates.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
### All admin users are assumed to be trusted
|
||||
|
||||
ArchiveBox does not support granular user permissions, there are only two types of users available in the Admin UI & REST API:
|
||||
|
||||
- logged in admin superusers (which are assumed to have `root` on the ArchiveBox host machine already)
|
||||
- non-logged in anonymous guest users
|
||||
|
||||
Because admin / REST API / CLI / Python API users are already assumed to have `root` on the host machine, there are deliberately no protections against admins inputting malicious config values, attempts to attack other admin users, or destructive/malicious actions. Permissions for non-logged in users are managed via the [`PERMISSIONS`](https://github.com/archiveBox/archiveBox/wiki/configuration#permissions) + [`PUBLIC_INDEX` + `PUBLIC_ADD_VIEW`](https://github.com/archiveBox/archiveBox/wiki/configuration#public_index--public_add_view) + other config options.
|
||||
|
||||
### Sanitization of Config Values, Filesystem Paths, DB Fields, etc.
|
||||
|
||||
Non-logged in users can only choose between public Persona config presets on the `/add/` page, but they should never be allowed to directly set config options like `*_ARGS`, `*_BINARY` or DB fields like `url`, `title`, `tag.name`, `notes` without sanitization/filtering on save and/or use.
|
||||
|
||||
Non-logged in users should be tightly limited to prevent attacks like SQL/shell injection, path traversal attacks, XSS, cookie leaks between admin/non-admin domains, etc. Any reports related to non-logged-in users will be prioritized as this is a surface we care a lot about hardening.
|
||||
|
||||
### XSS, CSRF, CORS, CSP
|
||||
|
||||
`< v0.9.x` versions do not include any XSS/CSRF protections at all, as documented in the [readme](https://github.com/ArchiveBox/ArchiveBox/tree/v0.7.4#security-risks-of-viewing-archived-js), [wiki](https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive#security-concerns), [issue #239](https://github.com/ArchiveBox/ArchiveBox/issues/239), and [existing advisory](https://github.com/ArchiveBox/ArchiveBox/security/advisories/GHSA-cr45-98w9-gwqx). Do not open advisories related to XSS or CSRF for those versions, as they are missing fundamental architectural changes added in `v0.9.0` needed to support per-snapshot replay isolation.
|
||||
|
||||
[`SERVER_SECURITY_MODE`](https://github.com/archiveBox/archiveBox/wiki/configuration#server_security_mode) + CSRF/XSS defenses were only added in the new 0.9.0 `dev` work and are planned to land in `main` in mid-2026.
|
||||
|
||||
Note archivebox is designed to be able to archive any URL the server can reach, including internal intranet URLs. The only URLs that are blocked are URLs hosted by the archivebox server itself.
|
||||
@@ -13,7 +13,6 @@ on:
|
||||
env:
|
||||
DOCKERHUB_IMAGE: archivebox/archivebox
|
||||
GHCR_IMAGE: ghcr.io/archivebox/archivebox
|
||||
ABX_DL_IMAGE: archivebox/abx-dl:latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -69,9 +68,12 @@ jobs:
|
||||
missing = required.copy()
|
||||
while missing and time.monotonic() < deadline:
|
||||
for name, version in list(missing.items()):
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
|
||||
releases = json.load(resp)["releases"]
|
||||
if version in releases:
|
||||
try:
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{version}/json", timeout=20):
|
||||
available = True
|
||||
except Exception:
|
||||
available = False
|
||||
if available:
|
||||
print(f"{name} {version} is available on PyPI")
|
||||
missing.pop(name)
|
||||
else:
|
||||
@@ -102,6 +104,23 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
ABX_DL_VERSION="$(python3 - <<'PY'
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
deps = tomllib.load(f)["project"]["dependencies"]
|
||||
|
||||
for dep in deps:
|
||||
match = re.match(r"abx-dl\s*(?:==|>=)\s*([^,;\s]+)", dep)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise SystemExit("Missing abx-dl dependency in pyproject.toml")
|
||||
PY
|
||||
)"
|
||||
ABX_DL_IMAGE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}"
|
||||
deadline=$((SECONDS + 1800))
|
||||
until docker buildx imagetools inspect "${ABX_DL_IMAGE}" >/tmp/abx-dl-image.json; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
@@ -170,12 +189,43 @@ jobs:
|
||||
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
|
||||
cache-from: type=gha,scope=${{ matrix.cache_scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }}
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
- name: Validate pushed image version, commit, and size
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
IMAGE="${DOCKERHUB_IMAGE}@${{ steps.docker_build.outputs.digest }}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
|
||||
DATA_DIR="$(mktemp -d)"
|
||||
docker run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init
|
||||
docker run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox version | tee /tmp/archivebox-version.out
|
||||
docker run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox status
|
||||
docker run --rm --platform '${{ matrix.platform }}' --entrypoint cat "$IMAGE" /VERSION.txt > /tmp/archivebox-image-version.txt
|
||||
grep -F "ArchiveBox v${{ steps.docker_meta.outputs.version }}" /tmp/archivebox-version.out
|
||||
grep -F "COMMIT_HASH=${SHORT_SHA}" /tmp/archivebox-version.out
|
||||
grep -Fx "COMMIT_HASH=${GITHUB_SHA}" /tmp/archivebox-image-version.txt
|
||||
|
||||
python3 - "$IMAGE" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
image = sys.argv[1]
|
||||
limit = 780 * 1024 * 1024
|
||||
manifest = json.loads(subprocess.check_output(["docker", "manifest", "inspect", image]))
|
||||
total = manifest.get("config", {}).get("size", 0) + sum(layer.get("size", 0) for layer in manifest.get("layers", []))
|
||||
print(f"{image} compressed_size={total / 1024 / 1024:.2f} MiB limit={limit / 1024 / 1024:.2f} MiB")
|
||||
if total > limit:
|
||||
raise SystemExit(f"{image} is over the compressed size limit")
|
||||
PY
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+64
-16
@@ -47,7 +47,6 @@ jobs:
|
||||
- name: Wait for released ArchiveBox deps on PyPI
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
@@ -67,9 +66,12 @@ jobs:
|
||||
missing = required.copy()
|
||||
while missing and time.monotonic() < deadline:
|
||||
for name, version in list(missing.items()):
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
|
||||
releases = json.load(resp)["releases"]
|
||||
if version in releases:
|
||||
try:
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{version}/json", timeout=20):
|
||||
available = True
|
||||
except Exception:
|
||||
available = False
|
||||
if available:
|
||||
print(f"{name} {version} is available on PyPI")
|
||||
missing.pop(name)
|
||||
else:
|
||||
@@ -82,8 +84,49 @@ jobs:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Prepare released dependency sync project
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
source = Path("pyproject.toml")
|
||||
target_dir = Path(".ci-uv-project")
|
||||
target_dir.mkdir(exist_ok=True)
|
||||
text = source.read_text()
|
||||
text = text.replace(
|
||||
'environments = ["sys_platform == \'darwin\'", "sys_platform == \'linux\'"]',
|
||||
'environments = ["sys_platform == \'linux\'"]',
|
||||
)
|
||||
|
||||
# CI runs immediately after publishing internal abx packages. PyPI's
|
||||
# simple index can lag the version JSON endpoint, so sync against the
|
||||
# exact wheel URLs from the version endpoint while leaving package
|
||||
# metadata in pyproject.toml unchanged for the actual build.
|
||||
for package in ("abxpkg", "abx-plugins", "abx-dl"):
|
||||
match = re.search(rf'"{re.escape(package)}>=(?P<version>[^"]+)"', text)
|
||||
if not match:
|
||||
continue
|
||||
version = match.group("version")
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=20) as response:
|
||||
data = json.load(response)
|
||||
wheel_url = next(url["url"] for url in data["urls"] if url["filename"].endswith(".whl"))
|
||||
text = re.sub(
|
||||
rf'"{re.escape(package)}>=[^"]+"',
|
||||
f'"{package} @ {wheel_url}"',
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
|
||||
(target_dir / "pyproject.toml").write_text(text)
|
||||
PY
|
||||
|
||||
- name: UV install archivebox dev + run sub-dependencies
|
||||
run: uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv
|
||||
run: uv sync --project .ci-uv-project --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
|
||||
|
||||
- name: UV build archivebox and archivebox/pkgs/* packages
|
||||
run: |
|
||||
@@ -93,17 +136,22 @@ jobs:
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
# ignore when publish to PyPI fails due to duplicate tag
|
||||
continue-on-error: true
|
||||
with:
|
||||
password: ${{ secrets.PYPI_PAT_SECRET }}
|
||||
|
||||
- name: UV install archivebox and archivebox/pkgs/* locally for tests
|
||||
run: uv sync --all-extras --no-sources --no-cache
|
||||
|
||||
- name: UV run archivebox init + archivebox version
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv
|
||||
run: |
|
||||
mkdir -p data && cd data
|
||||
uv run --no-sync --no-sources archivebox init \
|
||||
&& uv run --no-sync --no-sources archivebox version
|
||||
# && uv run archivebox add 'https://example.com' \
|
||||
# && uv run archivebox status \
|
||||
# || (echo "UV Failed to run archivebox!" && exit 1)
|
||||
uv sync --project .ci-uv-project --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
|
||||
uv pip install --python .venv/bin/python --no-deps dist/archivebox-*.whl
|
||||
|
||||
- name: Verify built package full install
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
DATA_DIR="$(mktemp -d)"
|
||||
cd "$DATA_DIR"
|
||||
uv run --project "$GITHUB_WORKSPACE/.ci-uv-project" --no-sync --no-sources archivebox init
|
||||
timeout 30m uv run --project "$GITHUB_WORKSPACE/.ci-uv-project" --no-sync --no-sources archivebox install
|
||||
uv run --project "$GITHUB_WORKSPACE/.ci-uv-project" --no-sync --no-sources archivebox version
|
||||
uv run --project "$GITHUB_WORKSPACE/.ci-uv-project" --no-sync --no-sources archivebox status
|
||||
@@ -3,26 +3,30 @@ name: Release State
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-state:
|
||||
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'release:')
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -44,7 +48,13 @@ jobs:
|
||||
|
||||
- name: Run release script
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PYPI_PAT_SECRET: ${{ secrets.PYPI_PAT_SECRET }}
|
||||
DEFAULT_BRANCH: dev
|
||||
GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
run: ./bin/release.sh
|
||||
|
||||
- name: Dispatch image and tap updates
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
run: |
|
||||
gh workflow run docker.yml --ref dev
|
||||
gh workflow run update-homebrew-tap.yml --ref dev
|
||||
@@ -81,10 +81,15 @@ jobs:
|
||||
pwd
|
||||
ls
|
||||
|
||||
- name: Archivebox version
|
||||
- name: ArchiveBox full install check
|
||||
run: |
|
||||
mkdir -p tests/out/data
|
||||
DATA_DIR="$PWD/tests/out/data" uv run --no-sync --no-sources archivebox version
|
||||
set -Eeuo pipefail
|
||||
DATA_DIR="$PWD/tests/out/data"
|
||||
mkdir -p "$DATA_DIR"
|
||||
uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox init
|
||||
timeout 30m uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox install
|
||||
uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox version
|
||||
uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox status
|
||||
|
||||
- name: Test built package with pytest
|
||||
# TODO: remove this exception for windows once we get tests passing on that platform
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Update Homebrew tap
|
||||
name: Update downstream packages
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,7 +9,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-homebrew-tap-${{ github.ref }}
|
||||
group: update-downstream-packages-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -17,19 +17,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Dispatch tap update
|
||||
- name: Dispatch downstream package updates
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
DOWNSTREAM_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
|
||||
echo "HOMEBREW_TAP_TOKEN is not configured; tap hourly fallback will update the formula."
|
||||
if [ -z "$DOWNSTREAM_TOKEN" ]; then
|
||||
echo "RELEASE_GH_TOKEN/HOMEBREW_TAP_TOKEN is not configured; downstream schedules will update packages."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${HOMEBREW_TAP_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/ArchiveBox/homebrew-archivebox/dispatches \
|
||||
-d '{"event_type":"archivebox-dev-updated"}'
|
||||
for repo in homebrew-archivebox debian-archivebox; do
|
||||
curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${DOWNSTREAM_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/repos/ArchiveBox/${repo}/dispatches" \
|
||||
-d '{"event_type":"archivebox-dev-updated","client_payload":{"ref":"dev"}}'
|
||||
done
|
||||
@@ -1,14 +1,87 @@
|
||||
I found 31 unique `no mocking` prompts in recent Codex history, across 21 session transcripts. Only one transcript literally started with `no mocking`; most had it later as testing guidance. Consolidated advice:
|
||||
# ArchiveBox Agent Guide
|
||||
|
||||
- Tests must hit real user-facing code paths: CLI commands, REST/API calls, browser UI, real hooks, real ArchiveBox data dirs, real pytest fixtures, and real subprocess/binary behavior.
|
||||
- No mocking, faking, simulating, monkey patching, handwritten fake objects, fake buses, fake hooks, fake binaries, fake handlers, or direct-post shortcuts when the user path is through UI/extension/CLI.
|
||||
- No skipped, xfailed, flaky, or “works around platform” tests. Flakiness is treated as a bug, especially on macOS/browser flows.
|
||||
- Prefer live integration tests over narrow unit tests when behavior depends on browsers, binaries, ArchiveBox crawls, plugins, LLMs, or server state.
|
||||
- Assertions must validate real correctness: returned values, exit codes, DB rows, filesystem contents, field values, uploaded files, rendered output, and side effects. “No error occurred” or “attribute exists” is not enough.
|
||||
- Start fixes with failing red tests that reproduce the missing behavior or regression, then implement the minimal fix and confirm the test passes.
|
||||
- Use realistic setup patterns “like a user would”: events + bus + handlers, real browser pages/CDP sessions, real URLs or `pytest-httpserver`, real rows, real snapshots, real installs, real local browser/server state.
|
||||
- For ArchiveBox/API tests, use existing `conftest.py` fixtures and test harnesses, real test DB rows/data dirs, and user-facing commands/APIs rather than bespoke helpers.
|
||||
- For browser/extension tests, trigger behavior through the real extension UI or actual browser session, not direct posting or mocked browser/session objects.
|
||||
- For binary/provider tests, use real binaries and real installs; verify constraints and final installed package metadata, not just install success.
|
||||
- For coverage quality, keep tests strict, deterministic, grouped consistently, and use a few larger realistic tests when that gives better surface coverage than many tiny fake unit tests.
|
||||
- Avoid weakening test coverage, adding compatibility/shim/fallback layers, or guessing from code shape. Trace root causes, verify assumptions with tests/scripts, and let real type/parse errors surface normally.
|
||||
ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev` branch.
|
||||
|
||||
## Shared Standards
|
||||
|
||||
- Use `uv` and `uv run` for Python commands. Do not use system `python`, direct `.venv/bin/python`, or `pip` commands.
|
||||
- Prefer existing repo patterns, helper APIs, fixtures, scripts, and command surfaces.
|
||||
- Keep edits focused and minimal. Do not add wrappers, shims, aliases, or extra abstraction layers unless the current code path requires them.
|
||||
- Do not weaken assertions, skip tests, xfail tests, or accept flaky behavior.
|
||||
- No mocks, monkeypatches, fakes, simulated handlers, fake binaries, fake hooks, fake buses, or direct shortcuts around user-facing flows.
|
||||
- Tests and verification should use real CLI commands, REST/API calls, browser UI flows, real hooks, real installs, real subprocesses, real DB rows, real files, and existing fixtures.
|
||||
- Assertions must verify real correctness: exit codes, returned values, DB state, filesystem contents, field values, rendered output, and side effects.
|
||||
- Start behavior fixes with a red failing test when a test is requested or practical.
|
||||
- Trace root causes from observed behavior. Do not paper over failures with retries, wider timeouts, broad fallbacks, or looser assertions.
|
||||
- Read `README.md` for the full setup, CLI, Docker, API, and release surface.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
uv sync --dev --all-extras
|
||||
mkdir -p data
|
||||
cd data
|
||||
uv run --project .. archivebox init --install
|
||||
```
|
||||
|
||||
Run collection commands from inside an initialized data directory:
|
||||
|
||||
```bash
|
||||
cd data
|
||||
uv run --project .. archivebox status
|
||||
uv run --project .. archivebox add 'https://example.com'
|
||||
uv run --project .. archivebox run
|
||||
```
|
||||
|
||||
## User-Facing Setup
|
||||
|
||||
Recommended CLI install:
|
||||
|
||||
```bash
|
||||
uv tool install archivebox
|
||||
mkdir -p ~/archivebox/data
|
||||
cd ~/archivebox/data
|
||||
archivebox init --install
|
||||
archivebox add 'https://example.com'
|
||||
archivebox server 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Alternative install methods:
|
||||
|
||||
- Docker Compose / Docker
|
||||
- Homebrew
|
||||
- Debian package
|
||||
- pip
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
archivebox version
|
||||
archivebox help
|
||||
archivebox status
|
||||
archivebox install
|
||||
archivebox add 'https://example.com'
|
||||
archivebox add --extract=title,screenshot,pdf 'https://example.com'
|
||||
archivebox list --json --with-headers
|
||||
archivebox search 'example'
|
||||
archivebox update --filter-type=domain example.com
|
||||
archivebox remove --filter-type=exact 'https://example.com'
|
||||
archivebox run
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Use targeted tests for focused work:
|
||||
|
||||
<!--pytest.mark.skip(reason="pytest invocation")-->
|
||||
```bash
|
||||
uv run pytest archivebox/tests/test_cli_add.py -q
|
||||
uv run prek run --all-files
|
||||
```
|
||||
|
||||
Use the full release/deploy loop only when requested:
|
||||
|
||||
<!--pytest.mark.skip(reason="release/deploy script")-->
|
||||
```bash
|
||||
./bin/release_dev_stack.sh
|
||||
```
|
||||
+94
-65
@@ -2,7 +2,7 @@
|
||||
|
||||
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
|
||||
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
|
||||
# ArchiveBox owns ripgrep, sonic, supervisor, Django, and the app runtime.
|
||||
# ArchiveBox owns sonic, supervisor, Django, and the app runtime.
|
||||
# Build abx-dl first, then point this file at it:
|
||||
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
|
||||
# --build-context abxbus=../abxbus \
|
||||
@@ -41,18 +41,16 @@ ENV PYTHON_VERSION=3.13 \
|
||||
NODE_VERSION=24
|
||||
|
||||
ENV ARCHIVEBOX_USER=archivebox \
|
||||
DEFAULT_PUID=911 \
|
||||
DEFAULT_PGID=911 \
|
||||
DEFAULT_ARCHIVEBOX_UID=911 \
|
||||
DEFAULT_ARCHIVEBOX_GID=911 \
|
||||
IN_DOCKER=True
|
||||
|
||||
ENV CODE_DIR=/app \
|
||||
DATA_DIR=/data \
|
||||
CONFIG_DIR=/opt/archivebox \
|
||||
LIB_DIR=/opt/archivebox/lib \
|
||||
ABXPKG_LIB_DIR=/opt/archivebox/lib \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \
|
||||
PERSONAS_DIR=/data/personas \
|
||||
CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \
|
||||
CHROME_HEADLESS=true \
|
||||
CHROME_SANDBOX=false \
|
||||
CHROME_ISOLATION=crawl \
|
||||
@@ -66,7 +64,7 @@ ENV TMP_DIR=/tmp/archivebox \
|
||||
|
||||
ENV HOME=/home/archivebox \
|
||||
XDG_CONFIG_HOME=/home/archivebox/.config \
|
||||
XDG_CACHE_HOME=/home/archivebox/.cache \
|
||||
XDG_CACHE_HOME=/opt/archivebox/lib/cache \
|
||||
ABXPKG_INSTALL_TIMEOUT=600 \
|
||||
ABXPKG_POSTINSTALL_SCRIPTS=True \
|
||||
ABXPKG_MIN_RELEASE_AGE=0 \
|
||||
@@ -89,44 +87,79 @@ RUN cp /VERSION.txt /ABX-DL-VERSION.txt \
|
||||
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
|
||||
&& uname -a \
|
||||
&& sed -n '1,7p' /etc/os-release \
|
||||
&& which node && node --version \
|
||||
&& which uv && uv self version \
|
||||
&& abxpkg load --binproviders=env node \
|
||||
&& abxpkg load --binproviders=env uv \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] APT Installing ArchiveBox search dependency ripgrep for $TARGETPLATFORM..." \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM archivebox-runtime-base AS archivebox-builder
|
||||
|
||||
WORKDIR "$CODE_DIR"
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
|
||||
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..." \
|
||||
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache \
|
||||
&& echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends \
|
||||
&& echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests \
|
||||
&& rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
|
||||
&& /usr/bin/uv venv --clear /venv --python "${PYTHON_VERSION}" \
|
||||
&& /usr/bin/uv pip install setuptools pip wheel \
|
||||
&& /usr/bin/uv sync \
|
||||
--refresh \
|
||||
--no-dev \
|
||||
--inexact \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-sources \
|
||||
&& (find /venv/lib/python3.*/site-packages -type f -name '*.so' -exec strip --strip-unneeded {} + 2>/dev/null || true) \
|
||||
&& rm -f /venv/bin/uv /venv/bin/uvx \
|
||||
&& apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
<<'EOF'
|
||||
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache
|
||||
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends
|
||||
echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update -qq
|
||||
apt-get install -qq -y --no-install-recommends \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev
|
||||
/usr/bin/uv venv --clear /venv --python "${PYTHON_VERSION}"
|
||||
/usr/bin/uv pip install setuptools pip wheel
|
||||
|
||||
mkdir -p /tmp/archivebox-uv-project
|
||||
/venv/bin/python - <<'PY'
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
source = Path("/app/pyproject.toml")
|
||||
target = Path("/tmp/archivebox-uv-project/pyproject.toml")
|
||||
text = source.read_text()
|
||||
text = text.replace(
|
||||
'environments = ["sys_platform == \'darwin\'", "sys_platform == \'linux\'"]',
|
||||
'environments = ["sys_platform == \'linux\'"]',
|
||||
)
|
||||
|
||||
# Docker builds need the just-published internal abx wheels immediately, but
|
||||
# PyPI simple can lag the version JSON endpoints by tens of minutes. Generate a
|
||||
# Docker-only dependency view from the version JSON so the published package
|
||||
# metadata stays normal while image builds remain resumable after a release.
|
||||
for package in ("abxpkg", "abx-plugins", "abx-dl"):
|
||||
match = re.search(rf'"{re.escape(package)}>=(?P<version>[^"]+)"', text)
|
||||
if not match:
|
||||
continue
|
||||
version = match.group("version")
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=20) as response:
|
||||
data = json.load(response)
|
||||
wheel_url = next(url["url"] for url in data["urls"] if url["filename"].endswith(".whl"))
|
||||
text = re.sub(
|
||||
rf'"{re.escape(package)}>=[^"]+"',
|
||||
f'"{package} @ {wheel_url}"',
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
|
||||
target.write_text(text)
|
||||
PY
|
||||
|
||||
/usr/bin/uv sync \
|
||||
--project /tmp/archivebox-uv-project \
|
||||
--refresh \
|
||||
--no-dev \
|
||||
--inexact \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-sources
|
||||
(find /venv/lib/python3.*/site-packages -type f -name '*.so' -exec strip --strip-unneeded {} + 2>/dev/null || true)
|
||||
rm -f /venv/bin/uv /venv/bin/uvx
|
||||
apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev
|
||||
apt-get autoremove -y
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
EOF
|
||||
|
||||
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
@@ -144,8 +177,7 @@ RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$T
|
||||
&& if [[ "$COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "COMMIT_HASH=$COMMIT_HASH" | tee -a /VERSION.txt; fi \
|
||||
&& /usr/bin/uv pip install --no-deps "$CODE_DIR" \
|
||||
&& rm -f /venv/bin/uv /venv/bin/uvx \
|
||||
&& (/usr/bin/uv pip show archivebox && which archivebox) | tee -a /VERSION.txt \
|
||||
&& rm -rf "$CODE_DIR/.git"
|
||||
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
|
||||
|
||||
FROM archivebox-runtime-base
|
||||
|
||||
@@ -168,7 +200,7 @@ COPY --from=archivebox-builder /venv /venv
|
||||
COPY --from=archivebox-builder /app /app
|
||||
COPY --from=archivebox-builder /VERSION.txt /VERSION.txt
|
||||
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
|
||||
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
|
||||
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
|
||||
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
|
||||
@@ -177,47 +209,44 @@ RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
|
||||
&& getent group "$ARCHIVEBOX_USER" >/dev/null || groupadd --system "$ARCHIVEBOX_USER" \
|
||||
&& id -u "$ARCHIVEBOX_USER" >/dev/null 2>&1 || useradd --system --create-home --gid "$ARCHIVEBOX_USER" --groups audio,video "$ARCHIVEBOX_USER" \
|
||||
&& usermod --append --groups audio,video "$ARCHIVEBOX_USER" \
|
||||
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_PUID" ]] || usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
|
||||
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_PGID" ]] || groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
|
||||
&& (which sonic && sonic --version) | tee -a /VERSION.txt \
|
||||
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER" "/home/$ARCHIVEBOX_USER/.cache" \
|
||||
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" "/home/$ARCHIVEBOX_USER/.cache/pnpm" "/home/$ARCHIVEBOX_USER/.cache/uv" \
|
||||
&& chown "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "/home/$ARCHIVEBOX_USER/.cache/abxbus" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \
|
||||
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_UID" ]] || usermod -u "$DEFAULT_ARCHIVEBOX_UID" "$ARCHIVEBOX_USER" \
|
||||
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_GID" ]] || groupmod -g "$DEFAULT_ARCHIVEBOX_GID" "$ARCHIVEBOX_USER" \
|
||||
&& abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
|
||||
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
|
||||
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
|
||||
&& chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$ABXPKG_LIB_DIR" \
|
||||
&& openssl rand -hex 16 > /etc/machine-id \
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u "$ARCHIVEBOX_USER") PGID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
|
||||
&& echo -e "TMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
|
||||
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
|
||||
WORKDIR "$DATA_DIR"
|
||||
RUN echo "[+] Initializing image collection..." \
|
||||
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
|
||||
&& archivebox init \
|
||||
&& (chown "$DEFAULT_PUID:$DEFAULT_PGID" \
|
||||
&& HOME="$TMP_DIR" archivebox init \
|
||||
&& (chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" \
|
||||
"$DATA_DIR" "$DATA_DIR"/.archivebox_id "$DATA_DIR"/ArchiveBox.conf "$DATA_DIR"/index.sqlite3 \
|
||||
"$DATA_DIR"/logs "$DATA_DIR"/logs/* "$DATA_DIR"/sources \
|
||||
"$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \
|
||||
"$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \
|
||||
"$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "/home/$ARCHIVEBOX_USER/.cache" \
|
||||
"$CONFIG_DIR" "$CONFIG_DIR"/config.env "$CONFIG_DIR"/derived.env \
|
||||
"$TMP_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
2>/dev/null || true) \
|
||||
&& find "$TMP_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
|
||||
RUN chmod +x "$CODE_DIR"/bin/*.sh \
|
||||
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
|
||||
&& chmod g+w "$TMP_DIR" "$ABXPKG_LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
|
||||
|
||||
RUN "$LIB_DIR/playwright/bin/chromium" --version | tee -a /VERSION.txt \
|
||||
&& "$LIB_DIR/uv/packages/papers-dl/venv/bin/papers-dl" --version | tee -a /VERSION.txt \
|
||||
&& /usr/bin/rg --version | head -1 | tee -a /VERSION.txt \
|
||||
&& /usr/local/bin/sonic --version | tee -a /VERSION.txt \
|
||||
&& /venv/bin/supervisord --version | tee -a /VERSION.txt \
|
||||
&& for forbidden_bin in gcc g++ make; do ! command -v "$forbidden_bin" || (echo "Unexpected build tool in runtime: $forbidden_bin=$(command -v "$forbidden_bin")" >&2 && exit 1); done \
|
||||
&& stat -c "%U:%G %a %n" "$CONFIG_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
RUN --mount=type=cache,target=/tmp/abxpkg-cache,sharing=locked,mode=1777 \
|
||||
for forbidden_bin in gcc g++ make; do ! abxpkg load --binproviders=env "$forbidden_bin" >/dev/null 2>&1 || (echo "Unexpected build tool in runtime: $forbidden_bin" >&2 && exit 1); done \
|
||||
&& stat -c "%U:%G %a %n" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$CONFIG_DIR" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$LIB_DIR" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox version 2>&1 | tee -a /VERSION.txt \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER/.cache" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "/home/$ARCHIVEBOX_USER/.cache/uv" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abx-dl install \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$ABXPKG_LIB_DIR" \
|
||||
&& python3 -c 'from abx_dl.models import discover_plugins; [print(f"export {plugin.enabled_key}=True") for plugin in discover_plugins(runtime="archivebox").values() if plugin.enabled_key in plugin.config.properties]' > /tmp/archivebox-enable-plugins.env \
|
||||
&& sort /tmp/archivebox-enable-plugins.env | tee -a /VERSION.txt \
|
||||
&& source /tmp/archivebox-enable-plugins.env \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups env HOME=/tmp/abxpkg-cache ABXPKG_NO_CACHE=True ABXPKG_TMP_CACHE_DIR=/tmp/abxpkg-cache archivebox install \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups env HOME=/tmp/abxpkg-cache archivebox version 2>&1 | tee -a /VERSION.txt \
|
||||
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
|
||||
|
||||
RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully." \
|
||||
|
||||
@@ -29,7 +29,7 @@ Without active preservation effort, everything on the internet eventually disapp
|
||||
*ArchiveBox is an open source tool that lets organizations & individuals archive both public & private web content while retaining control over their data. It can be used to save copies of bookmarks, preserve evidence for legal cases, backup photos from FB/Insta/Flickr or media from YT/Soundcloud/etc., save research papers, and more...*
|
||||
<br/>
|
||||
|
||||
> ➡️ Get ArchiveBox with `pip install archivebox` on [Linux](#quickstart)/[macOS](#quickstart), or via **[Docker](#quickstart)** ⭐️ on any OS.
|
||||
> ➡️ Get ArchiveBox with `pip install archivebox>=0.9.5rc1` on [Linux](#quickstart)/[macOS](#quickstart), or via **[Docker](#quickstart)** ⭐️ on any OS.
|
||||
|
||||
*Once installed, you can interact with it through the: [Browser Extension](https://github.com/ArchiveBox/archivebox-browser-extension), [CLI](#usage), [self-hosted web interface](https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive), [Python API](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#python-shell-usage), or [filesystem](#static-archive-exporting).*
|
||||
|
||||
@@ -92,7 +92,7 @@ docker run -it -v $PWD:/data archivebox/archivebox init
|
||||
<br/>
|
||||
<br/>
|
||||
# Option C: Or install it with your preferred pkg manager (see Quickstart below for apt, brew, and more)
|
||||
pip install archivebox
|
||||
pip install 'archivebox>=0.9.5rc1'
|
||||
mkdir -p ~/archivebox/data && cd ~/archivebox/data
|
||||
archivebox init
|
||||
archivebox install
|
||||
@@ -1144,7 +1144,7 @@ Because ArchiveBox is designed to ingest a large volume of URLs with multiple co
|
||||
<li><strong>Don't store large collections on older filesystems like EXT3/FAT</strong> as they may not be able to handle more than 50k directory entries in the <code>data/archive/</code> folder.
|
||||
</li>
|
||||
<li><strong>Try to keep the <code>data/index.sqlite3</code> file on local drive (not a network mount)</strong> or SSD for maximum performance, however the <code>data/archive/</code> folder can be on a network mount or slower HDD.</li>
|
||||
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, you may need to set <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid"><code>PUID</code> & <code>PGID</code></a> and <a href="https://github.com/ArchiveBox/ArchiveBox/issues/1304">disable <code>root_squash</code></a> on your fileshare server.
|
||||
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, make sure the mounted data directory is writable by its intended owner and consider disabling <a href="https://github.com/ArchiveBox/ArchiveBox/issues/1304"><code>root_squash</code></a> on your fileshare server.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1154,7 +1154,6 @@ Because ArchiveBox is designed to ingest a large volume of URLs with multiple co
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#Disk-Layout">Wiki: Usage (Disk Layout)</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#output-folder">Wiki: Security Overview (Output-Folder)</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#large-archives">Wiki: Usage (Large Archives)</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid">Wiki: Configuration (<code>PUID</code> & <code>GUID</code>)</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#do-not-run-as-root">Wiki: Security Overview (Do Not Run as Root)</a></li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -121,14 +121,21 @@ def snapshot_filter_kwargs(args: SnapshotFilterCommandSchema, *, default_filter_
|
||||
@router.post("/add", response=CLICommandResponseSchema, summary="archivebox add [args] [urls]")
|
||||
def cli_add(request: HttpRequest, args: AddCommandSchema):
|
||||
from archivebox.cli.archivebox_add import add
|
||||
from archivebox.misc.util import validate_url
|
||||
|
||||
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
|
||||
submitted_urls: str | list[str] = args.urls
|
||||
if len(args.urls) == 1:
|
||||
try:
|
||||
validate_url(args.urls[0])
|
||||
except ValueError:
|
||||
submitted_urls = args.urls[0]
|
||||
crawl, snapshots = add(
|
||||
urls=args.urls,
|
||||
urls=submitted_urls,
|
||||
snapshot_ids=args.snapshot_ids,
|
||||
tag=args.tag,
|
||||
depth=args.depth,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
__package__ = "archivebox.api"
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -124,7 +125,7 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
|
||||
config = dict(data.config or {})
|
||||
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
|
||||
crawl = Crawl.objects.create(
|
||||
urls="\n".join(urls),
|
||||
urls="\n".join(json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in urls),
|
||||
max_depth=data.max_depth,
|
||||
tags_str=",".join(tags),
|
||||
label=data.label,
|
||||
|
||||
@@ -18,10 +18,11 @@ if "--debug" in sys.argv:
|
||||
|
||||
# Universal `--init` flag: when passed to ANY subcommand (e.g. `archivebox server --init`,
|
||||
# `archivebox add --init`, `archivebox shell --init`), run a `quick` archivebox init before
|
||||
# the subcommand executes. Strip it from argv here so each subcommand's own click parser
|
||||
# never sees it. Ignored for `help` and `init` themselves.
|
||||
if "--init" in sys.argv:
|
||||
sys.argv = [arg for arg in sys.argv if arg != "--init"]
|
||||
# the subcommand executes. `--quick-init` is the old server spelling. Strip either from
|
||||
# argv here so each subcommand's own click parser never sees it. Ignored for `help` and
|
||||
# `init` themselves.
|
||||
if "--init" in sys.argv or "--quick-init" in sys.argv:
|
||||
sys.argv = [arg for arg in sys.argv if arg not in ("--init", "--quick-init")]
|
||||
os.environ["ARCHIVEBOX_WANTS_INIT"] = "1"
|
||||
|
||||
|
||||
@@ -32,6 +33,7 @@ class ArchiveBoxGroup(click.Group):
|
||||
"help": "archivebox.cli.archivebox_help.main",
|
||||
"version": "archivebox.cli.archivebox_version.main",
|
||||
"mcp": "archivebox.cli.archivebox_mcp.main",
|
||||
"oneshot": "archivebox.cli.archivebox_oneshot.main",
|
||||
}
|
||||
setup_commands = {
|
||||
"init": "archivebox.cli.archivebox_init.main",
|
||||
|
||||
@@ -6,6 +6,7 @@ __command__ = "archivebox add"
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
@@ -114,7 +115,6 @@ def add(
|
||||
|
||||
# import models once django is set up
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.base_models.models import get_or_create_system_user_pk
|
||||
from archivebox.personas.models import Persona
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
@@ -125,7 +125,12 @@ def add(
|
||||
created_by_id = created_by_id or get_or_create_system_user_pk()
|
||||
started_at = timezone.now()
|
||||
|
||||
source_text = urls if isinstance(urls, str) else "\n".join(str(url) for url in urls)
|
||||
use_internal_input_root = isinstance(urls, str)
|
||||
source_text = (
|
||||
urls
|
||||
if use_internal_input_root
|
||||
else "\n".join(json.dumps({"type": "CrawlSeed", "url": str(url), "depth": 0}, separators=(",", ":")) for url in urls)
|
||||
)
|
||||
|
||||
# 2. Create a new Crawl with inline URLs
|
||||
cli_args = [*sys.argv]
|
||||
@@ -137,7 +142,7 @@ def add(
|
||||
|
||||
persona_name = (persona or "Default").strip() or "Default"
|
||||
plugins = plugins or ""
|
||||
persona_obj, _ = Persona.objects.get_or_create(name=persona_name)
|
||||
persona_obj = Persona.get_or_create_named(persona_name)
|
||||
persona_obj.ensure_dirs()
|
||||
effective_persona_config = get_config(persona=persona_obj)
|
||||
|
||||
@@ -170,10 +175,12 @@ def add(
|
||||
# that is not representable as one plain URL per line.
|
||||
crawl = Crawl.objects.create(
|
||||
urls=source_text,
|
||||
# The internal root snapshot occupies depth 0. URLs discovered from the
|
||||
# submitted source become normal child snapshots at depth 1, so the
|
||||
# effective crawl limit must be one hop deeper than the user requested.
|
||||
max_depth=depth + 1,
|
||||
# Stdin/import text gets an extra hop because the synthetic
|
||||
# archivebox://internal root lives at depth 0 and parser-discovered
|
||||
# URLs land at depth 1; direct URL args become the depth=0 input
|
||||
# snapshots themselves so --depth=N matches the deepest hop the user
|
||||
# asked for.
|
||||
max_depth=depth + 1 if use_internal_input_root else depth,
|
||||
tags_str=tag,
|
||||
persona_id=persona_obj.id,
|
||||
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
|
||||
@@ -183,19 +190,6 @@ def add(
|
||||
config=crawl_config,
|
||||
)
|
||||
|
||||
# Parser plugins consume this root snapshot through the normal Snapshot
|
||||
# hook lifecycle. ArchiveBox does not select parser plugins or call them
|
||||
# directly; non-parser plugins cheaply no-result unsupported internal input.
|
||||
root_snapshot = Snapshot.objects.create(
|
||||
url=Snapshot.INTERNAL_INPUT_URL,
|
||||
crawl=crawl,
|
||||
depth=0,
|
||||
title="stdin.txt",
|
||||
)
|
||||
staticfile_dir = root_snapshot.output_dir / "staticfile"
|
||||
staticfile_dir.mkdir(parents=True, exist_ok=True)
|
||||
(staticfile_dir / "stdin.txt").write_text(source_text, encoding="utf-8")
|
||||
|
||||
print(f"[green]\\[+] Created Crawl {crawl.id} with max_depth={depth}[/green]")
|
||||
first_url = crawl.get_urls_list()[0] if crawl.get_urls_list() else ""
|
||||
print(f" [dim]First URL: {first_url}[/dim]")
|
||||
@@ -220,6 +214,9 @@ def add(
|
||||
print(
|
||||
"[yellow]\\[*] URLs queued. The background runner will process them (run `archivebox server` or `archivebox run --daemon` if not already running).[/yellow]",
|
||||
)
|
||||
from archivebox.services.runner import ensure_background_runner
|
||||
|
||||
ensure_background_runner(allow_under_pytest=True)
|
||||
else:
|
||||
# Foreground mode: run full crawl runner until all work is done
|
||||
print("[green]\\[*] Starting crawl runner to process crawl...[/green]")
|
||||
@@ -334,6 +331,7 @@ def add(
|
||||
@click.option("--url-denylist", "--domain-denylist", default="", help="Comma-separated URL/domain denylist for this crawl")
|
||||
@click.option("--parser", default="auto", help="Parser for reading input URLs (auto, txt, html, rss, json, jsonl, netscape, ...)")
|
||||
@click.option("--plugins", "-p", default="", help="Comma-separated list of plugins to run e.g. title,favicon,screenshot,singlefile,...")
|
||||
@click.option("--extract", default="", help="Comma-separated plugins or output types to extract e.g. title,pdf,text/html,image,...")
|
||||
@click.option("--persona", default="Default", help="Authentication profile to use when archiving")
|
||||
@click.option(
|
||||
"--only-new/--no-only-new",
|
||||
@@ -383,10 +381,23 @@ def main(**kwargs):
|
||||
overwrite = kwargs.pop("overwrite", False)
|
||||
update = kwargs.pop("update", False)
|
||||
only_new = kwargs.pop("only_new", None)
|
||||
extract = kwargs.pop("extract", "")
|
||||
if overwrite or update:
|
||||
only_new = False
|
||||
if only_new is not None:
|
||||
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
|
||||
if extract:
|
||||
from abx_dl.models import discover_plugins, plugins_matching_output
|
||||
|
||||
all_plugins = discover_plugins()
|
||||
tokens = [token.strip() for token in extract.split(",") if token.strip()]
|
||||
plugin_names = {name.lower(): name for name in all_plugins}
|
||||
selected = [plugin_names[token.lower()] for token in tokens if token.lower() in plugin_names]
|
||||
selected += plugins_matching_output(all_plugins, tokens)
|
||||
if not selected:
|
||||
raise click.UsageError(f"No plugins found matching extract types: {extract}")
|
||||
existing = [token.strip() for token in (kwargs.get("plugins") or "").split(",") if token.strip()]
|
||||
kwargs["plugins"] = ",".join(dict.fromkeys([*existing, *selected]))
|
||||
|
||||
add(urls=urls, **kwargs)
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
|
||||
config = get_config()
|
||||
config.TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config.LIB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(config.LIB_DIR / "bin").mkdir(parents=True, exist_ok=True)
|
||||
config.ABXPKG_LIB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(config.ABXPKG_LIB_DIR / "bin").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
working_tmp_dir = get_or_create_working_tmp_dir(autofix=True, quiet=True)
|
||||
if working_tmp_dir:
|
||||
@@ -186,7 +186,7 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
@click.command()
|
||||
@click.option("--force", "-f", is_flag=True, help="Ignore unrecognized files in current directory and initialize anyway")
|
||||
@click.option("--quick", "-q", is_flag=True, help="Run any updates or migrations without rechecking all snapshot dirs")
|
||||
@click.option("--install", "-s", is_flag=True, help="Automatically install dependencies and extras used for archiving")
|
||||
@click.option("--install", "--setup", "-s", is_flag=True, help="Automatically install dependencies and extras used for archiving")
|
||||
@docstring(init.__doc__)
|
||||
def main(**kwargs) -> None:
|
||||
init(**kwargs)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
__package__ = "archivebox.cli"
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import rich_click as click
|
||||
from rich import print
|
||||
@@ -10,6 +12,38 @@ from rich import print
|
||||
from archivebox.misc.util import docstring, enforce_types
|
||||
|
||||
|
||||
def ensure_data_dir_lib_symlink(data_dir: Path, abxpkg_lib_dir: Path) -> Path | None:
|
||||
"""Expose the shared abxpkg lib dir at DATA_DIR/lib after install."""
|
||||
link_path = data_dir / "lib"
|
||||
target_config_path = abxpkg_lib_dir.expanduser().absolute()
|
||||
target_path = abxpkg_lib_dir.expanduser().resolve()
|
||||
from archivebox.config.paths import get_machine_type
|
||||
|
||||
scoped_data_lib_path = (link_path / get_machine_type()).absolute()
|
||||
|
||||
if target_config_path == scoped_data_lib_path:
|
||||
if link_path.is_symlink() or link_path.is_file():
|
||||
link_path.unlink()
|
||||
target_config_path.mkdir(parents=True, exist_ok=True)
|
||||
return None
|
||||
|
||||
if link_path.resolve(strict=False) == target_path:
|
||||
return None
|
||||
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if link_path.is_symlink():
|
||||
if link_path.resolve(strict=False) == target_path:
|
||||
return link_path
|
||||
link_path.unlink()
|
||||
elif link_path.exists():
|
||||
shutil.rmtree(link_path)
|
||||
|
||||
relative_target = os.path.relpath(target_path, start=link_path.parent)
|
||||
link_path.symlink_to(relative_target, target_is_directory=True)
|
||||
return link_path
|
||||
|
||||
|
||||
@enforce_types
|
||||
def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bool = False) -> None:
|
||||
"""Detect and install ArchiveBox dependencies by running the abx-dl install flow
|
||||
@@ -67,6 +101,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
|
||||
|
||||
run_install(plugin_names=plugin_names or None)
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(include_machine=False)
|
||||
ensure_data_dir_lib_symlink(CONSTANTS.DATA_DIR, config.ABXPKG_LIB_DIR)
|
||||
|
||||
print()
|
||||
|
||||
# Check for superuser
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox oneshot"
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import rich_click as click
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
|
||||
|
||||
@click.command(add_help_option=False, context_settings=dict(ignore_unknown_options=True))
|
||||
@click.argument("args", nargs=-1)
|
||||
def main(args: tuple[str, ...] = ()) -> None:
|
||||
"""Download URLs using abx-dl"""
|
||||
cwd = Path.cwd()
|
||||
if any((path / CONSTANTS.SQL_INDEX_FILENAME).exists() for path in (cwd, *cwd.parents)):
|
||||
raise click.ClickException(
|
||||
"Refusing to run `archivebox oneshot` inside an ArchiveBox DATA_DIR. Use `archivebox add` here, or run oneshot from another directory.",
|
||||
)
|
||||
raise SystemExit(subprocess.run(["abx-dl", *args]).returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -34,6 +34,7 @@ def remove(
|
||||
after: float | None = None,
|
||||
before: float | None = None,
|
||||
yes: bool = False,
|
||||
delete: bool = False,
|
||||
out_dir: Path = CONSTANTS.DATA_DIR,
|
||||
timeout: float | None = None,
|
||||
**kwargs,
|
||||
@@ -142,6 +143,7 @@ def remove(
|
||||
|
||||
@click.command()
|
||||
@click.option("--yes", is_flag=True, help="Remove links instantly without prompting to confirm")
|
||||
@click.option("--delete", is_flag=True, help="Compatibility no-op; archived content is deleted automatically")
|
||||
@click.option("--timeout", type=float, default=None, help="Maximum seconds to spend deleting snapshots")
|
||||
@snapshot_filter_options(default_filter_type="exact")
|
||||
@docstring(remove.__doc__)
|
||||
|
||||
@@ -287,7 +287,7 @@ def run_runner(
|
||||
current.mark_exited()
|
||||
return 0
|
||||
|
||||
recover_orchestrator_state(include_chrome=True)
|
||||
recover_orchestrator_state(include_chrome=crawl_id is None, crawl_id=crawl_id)
|
||||
if crawl_id:
|
||||
from django.utils import timezone
|
||||
from archivebox.crawls.models import Crawl
|
||||
|
||||
@@ -231,6 +231,7 @@ def server(
|
||||
debug: bool = False,
|
||||
daemonize: bool = False,
|
||||
nothreading: bool = False,
|
||||
createsuperuser: bool = False,
|
||||
) -> None:
|
||||
"""Run the ArchiveBox HTTP server"""
|
||||
from archivebox.config.common import get_config
|
||||
@@ -244,6 +245,12 @@ def server(
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
if createsuperuser:
|
||||
from archivebox.cli.archivebox_manage import manage
|
||||
|
||||
manage(args=["createsuperuser"])
|
||||
print()
|
||||
|
||||
if not User.objects.filter(is_superuser=True).exclude(username="system").exists():
|
||||
print()
|
||||
print(
|
||||
@@ -404,6 +411,7 @@ def server(
|
||||
@click.option("--debug", is_flag=True, help="Enable DEBUG=True mode with more verbose errors")
|
||||
@click.option("--nothreading", is_flag=True, help="Force runserver to run in single-threaded mode")
|
||||
@click.option("--daemonize", is_flag=True, help="Run the server in the background as a daemon")
|
||||
@click.option("--createsuperuser", is_flag=True, help="Run archivebox manage createsuperuser before starting the server")
|
||||
@docstring(server.__doc__)
|
||||
def main(**kwargs):
|
||||
server(**kwargs)
|
||||
|
||||
@@ -29,8 +29,7 @@ def _format_binary_abspath(
|
||||
|
||||
candidate_bases: tuple[tuple[Path, str], ...] = (
|
||||
(pwd, "./"),
|
||||
(lib_dir, "LIB_DIR/"),
|
||||
(Path(os.environ.get("LIB_DIR", "")), "LIB_DIR/") if os.environ.get("LIB_DIR") else (Path(), ""),
|
||||
(lib_dir, "ABXPKG_LIB_DIR/"),
|
||||
(personas_dir, "PERSONAS_DIR/"),
|
||||
(home, "~/"),
|
||||
)
|
||||
@@ -47,8 +46,8 @@ def _format_binary_abspath(
|
||||
relative_str = relative.as_posix()
|
||||
if prefix == "./":
|
||||
return "." if not relative_str else f"./{relative_str}"
|
||||
if prefix == "LIB_DIR/":
|
||||
return "LIB_DIR" if not relative_str else f"LIB_DIR/{relative_str}"
|
||||
if prefix == "ABXPKG_LIB_DIR/":
|
||||
return "ABXPKG_LIB_DIR" if not relative_str else f"ABXPKG_LIB_DIR/{relative_str}"
|
||||
if prefix == "PERSONAS_DIR/":
|
||||
return "PERSONAS_DIR" if not relative_str else f"PERSONAS_DIR/{relative_str}"
|
||||
return "~" if not relative_str else f"~/{relative_str}"
|
||||
@@ -59,10 +58,10 @@ def _format_binary_abspath(
|
||||
def _render_binary_abspath(abspath: str):
|
||||
from rich.text import Text
|
||||
|
||||
if abspath.startswith("LIB_DIR/"):
|
||||
return Text.assemble(("LIB_DIR", "bright_blue"), (abspath.removeprefix("LIB_DIR"), "green"))
|
||||
if abspath == "LIB_DIR":
|
||||
return Text("LIB_DIR", style="bright_blue")
|
||||
if abspath.startswith("ABXPKG_LIB_DIR/"):
|
||||
return Text.assemble(("ABXPKG_LIB_DIR", "bright_blue"), (abspath.removeprefix("ABXPKG_LIB_DIR"), "green"))
|
||||
if abspath == "ABXPKG_LIB_DIR":
|
||||
return Text("ABXPKG_LIB_DIR", style="bright_blue")
|
||||
if abspath.startswith("PERSONAS_DIR/"):
|
||||
return Text.assemble(("PERSONAS_DIR", "medium_purple"), (abspath.removeprefix("PERSONAS_DIR"), "green"))
|
||||
if abspath == "PERSONAS_DIR":
|
||||
@@ -78,6 +77,62 @@ def _render_binary_abspath(abspath: str):
|
||||
return Text(abspath, style="green")
|
||||
|
||||
|
||||
def _build_binary_table(rows: list[dict[str, object]]):
|
||||
from rich import box
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(title="Binary Dependencies", box=box.SIMPLE_HEAVY, expand=True)
|
||||
table.add_column("Plugin", no_wrap=True, max_width=24)
|
||||
table.add_column("State", no_wrap=True, width=8)
|
||||
table.add_column("Status", justify="center", no_wrap=True, width=6)
|
||||
table.add_column("Binary", no_wrap=True, max_width=28)
|
||||
table.add_column("Version", no_wrap=True, width=16)
|
||||
table.add_column("Provider", no_wrap=True, width=8)
|
||||
table.add_column("Path", overflow="fold", ratio=1)
|
||||
for row in rows:
|
||||
table.add_row(
|
||||
str(row["plugin"]),
|
||||
str(row["state"]),
|
||||
str(row["status"]),
|
||||
str(row["binary"]),
|
||||
str(row["version"]),
|
||||
str(row["provider"]),
|
||||
row["path"],
|
||||
style=str(row.get("style") or ""),
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def _print_binary_row(prnt, row: dict[str, object]) -> None:
|
||||
prnt(
|
||||
"",
|
||||
row["status"],
|
||||
str(row["plugin"]).ljust(24),
|
||||
str(row["state"]).ljust(8),
|
||||
str(row["binary"]).ljust(28),
|
||||
str(row["version"]).ljust(16),
|
||||
str(row["provider"]).ljust(8),
|
||||
row["path"],
|
||||
overflow="ignore",
|
||||
crop=False,
|
||||
style=str(row.get("style") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _binary_record_matches_runtime(installed, lib_dir: Path) -> bool:
|
||||
if not installed or not installed.is_valid or not installed.version:
|
||||
return False
|
||||
try:
|
||||
abspath = Path(installed.abspath).expanduser().resolve(strict=False)
|
||||
if not abspath.exists():
|
||||
return False
|
||||
if installed.binprovider not in {"", "env", "apt", "brew"}:
|
||||
abspath.relative_to(lib_dir)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@enforce_types
|
||||
def version(
|
||||
quiet: bool = False,
|
||||
@@ -94,13 +149,14 @@ def version(
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.version import get_COMMIT_HASH, get_BUILD_TIME
|
||||
from archivebox.config.permissions import ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, RUNNING_AS_UID, RUNNING_AS_GID, IN_DOCKER
|
||||
from archivebox.config.paths import get_data_locations, get_code_locations
|
||||
from archivebox.misc.logging_util import printable_folder_status
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.config.common import get_config, normalize_runtime_config
|
||||
|
||||
console = Console()
|
||||
prnt = console.print
|
||||
@@ -154,7 +210,8 @@ def version(
|
||||
)
|
||||
prnt()
|
||||
|
||||
if not (os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)):
|
||||
in_data_dir = os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)
|
||||
if not in_data_dir:
|
||||
PANEL_TEXT = "\n".join(
|
||||
(
|
||||
"",
|
||||
@@ -175,92 +232,149 @@ def version(
|
||||
),
|
||||
)
|
||||
prnt()
|
||||
return []
|
||||
|
||||
prnt("[pale_green1][i] Binary Dependencies:[/pale_green1]")
|
||||
failures = []
|
||||
|
||||
# Setup Django before importing models
|
||||
from archivebox.plugins.discovery import get_enabled_plugins
|
||||
from abx_dl.config import get_required_binary_requests
|
||||
from abx_dl.dependencies import load_binary
|
||||
from abx_dl.models import discover_plugins, filter_plugins
|
||||
|
||||
if isinstance(binaries, str):
|
||||
requested_names = {name.strip() for name in binaries.split(",") if name.strip()}
|
||||
else:
|
||||
requested_names = {name for name in (binaries or ()) if name}
|
||||
|
||||
plugins = discover_plugins(runtime="archivebox")
|
||||
enabled_plugins = filter_plugins(plugins, get_enabled_plugins(config=config), include_providers=True)
|
||||
enabled_plugin_names = set(enabled_plugins)
|
||||
runtime_config = normalize_runtime_config(config.for_crawl(), json_safe=False)
|
||||
derived_config: dict[str, object] = {}
|
||||
db_binaries = {}
|
||||
db_available = False
|
||||
if in_data_dir:
|
||||
try:
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django()
|
||||
|
||||
from archivebox.machine.models import Machine, Binary
|
||||
|
||||
machine = Machine.current()
|
||||
derived_config = normalize_runtime_config(machine.config, json_safe=False)
|
||||
for binary in Binary.objects.filter(machine=machine).order_by("name", "-modified_at"):
|
||||
if _binary_record_matches_runtime(binary, config.ABXPKG_LIB_DIR):
|
||||
db_binaries.setdefault(binary.name, binary)
|
||||
db_available = True
|
||||
|
||||
except Exception as e:
|
||||
prnt()
|
||||
prnt("", f"[yellow]Warning: Could not query collection binary records, falling back to abxpkg state: {e}[/yellow]")
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
any_rows = False
|
||||
any_available = False
|
||||
compact_paths = console.is_terminal
|
||||
live_enabled = console.is_terminal
|
||||
live_cm = Live(_build_binary_table(rows), console=console, refresh_per_second=8) if live_enabled else None
|
||||
if not live_enabled:
|
||||
prnt("", "Status", "Plugin".ljust(24), "State".ljust(8), "Binary".ljust(28), "Version".ljust(16), "Provider".ljust(8), "Path")
|
||||
|
||||
def emit_row(row: dict[str, object]) -> None:
|
||||
rows.append(row)
|
||||
if live_cm is not None:
|
||||
live_cm.update(_build_binary_table(rows), refresh=True)
|
||||
else:
|
||||
_print_binary_row(prnt, row)
|
||||
|
||||
if live_cm is not None:
|
||||
live_cm.start()
|
||||
try:
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django()
|
||||
|
||||
from archivebox.machine.models import Machine, Binary
|
||||
|
||||
machine = Machine.current()
|
||||
|
||||
if isinstance(binaries, str):
|
||||
requested_names = {name.strip() for name in binaries.split(",") if name.strip()}
|
||||
else:
|
||||
requested_names = {name for name in (binaries or ()) if name}
|
||||
|
||||
db_binaries: dict[str, Binary] = {}
|
||||
for binary in Binary.objects.filter(machine=machine).order_by("name", "-modified_at"):
|
||||
db_binaries.setdefault(binary.name, binary)
|
||||
|
||||
all_binary_names = sorted(requested_names or set(db_binaries.keys()))
|
||||
|
||||
if not all_binary_names:
|
||||
prnt("", "[grey53]No binaries detected. Run [green]archivebox install[/green] to detect dependencies.[/grey53]")
|
||||
else:
|
||||
any_available = False
|
||||
compact_paths = console.is_terminal
|
||||
for name in all_binary_names:
|
||||
if requested_names and name not in requested_names:
|
||||
for plugin_name, plugin in plugins.items():
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
logical_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
overrides=runtime_config,
|
||||
derived_overrides=derived_config,
|
||||
run_output_dir=CONSTANTS.DATA_DIR,
|
||||
)
|
||||
actual_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
overrides=runtime_config,
|
||||
derived_overrides=derived_config,
|
||||
run_output_dir=CONSTANTS.DATA_DIR,
|
||||
logical_names=False,
|
||||
)
|
||||
for logical_record, actual_record in zip(logical_records, actual_records, strict=False):
|
||||
logical_name = str(logical_record["name"])
|
||||
actual_name = str(actual_record["name"])
|
||||
display_name = Path(actual_name).expanduser().name if ("/" in actual_name or actual_name.startswith("~")) else logical_name
|
||||
if (
|
||||
requested_names
|
||||
and logical_name not in requested_names
|
||||
and actual_name not in requested_names
|
||||
and display_name not in requested_names
|
||||
):
|
||||
continue
|
||||
|
||||
installed = db_binaries.get(name)
|
||||
if installed and installed.is_valid:
|
||||
display_name = Path(name).expanduser().name if ("/" in name or name.startswith("~")) else name
|
||||
any_rows = True
|
||||
installed = db_binaries.get(logical_name) if db_available else None
|
||||
if _binary_record_matches_runtime(installed, config.ABXPKG_LIB_DIR):
|
||||
abspath = installed.abspath
|
||||
version_str = (installed.version or "unknown")[:15]
|
||||
provider = (installed.binprovider or "env")[:8]
|
||||
valid = True
|
||||
else:
|
||||
loaded = load_binary(actual_record)
|
||||
abspath = str(loaded.loaded_abspath or "")
|
||||
version_str = str(loaded.loaded_version or "unknown")[:15]
|
||||
provider = (loaded.loaded_binprovider.name if loaded.loaded_binprovider else "env")[:8]
|
||||
valid = loaded.is_valid
|
||||
|
||||
if valid:
|
||||
display_path = (
|
||||
_format_binary_abspath(
|
||||
installed.abspath,
|
||||
abspath,
|
||||
pwd=Path.cwd(),
|
||||
lib_dir=config.LIB_DIR,
|
||||
lib_dir=config.ABXPKG_LIB_DIR,
|
||||
personas_dir=CONSTANTS.PERSONAS_DIR,
|
||||
home=Path.home(),
|
||||
)
|
||||
if compact_paths
|
||||
else installed.abspath
|
||||
else abspath
|
||||
)
|
||||
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
|
||||
version_str = (installed.version or "unknown")[:15]
|
||||
provider = (installed.binprovider or "env")[:8]
|
||||
prnt(
|
||||
"",
|
||||
"[green]√[/green]",
|
||||
"",
|
||||
display_name.ljust(18),
|
||||
version_str.ljust(16),
|
||||
provider.ljust(8),
|
||||
rendered_path,
|
||||
overflow="ignore",
|
||||
crop=False,
|
||||
)
|
||||
status = "[green]√[/green]" if plugin_enabled else "[grey53]-[/grey53]"
|
||||
any_available = True
|
||||
continue
|
||||
else:
|
||||
rendered_path = "[grey53]not installed[/grey53]"
|
||||
status = "[red]X[/red]" if plugin_enabled else "[grey53]-[/grey53]"
|
||||
if plugin_enabled:
|
||||
failures.append(display_name)
|
||||
|
||||
status = (
|
||||
"[grey53]not recorded[/grey53]" if name in requested_names and installed is None else "[grey53]not installed[/grey53]"
|
||||
emit_row(
|
||||
{
|
||||
"plugin": plugin_name,
|
||||
"state": "enabled" if plugin_enabled else "disabled",
|
||||
"status": status,
|
||||
"binary": display_name,
|
||||
"version": version_str if valid else "-",
|
||||
"provider": provider if valid else "-",
|
||||
"path": rendered_path,
|
||||
"style": "" if plugin_enabled else "dim",
|
||||
},
|
||||
)
|
||||
prnt("", "[red]X[/red]", "", name.ljust(18), status, overflow="ignore", crop=False)
|
||||
failures.append(name)
|
||||
finally:
|
||||
if live_cm is not None:
|
||||
live_cm.stop()
|
||||
|
||||
if not any_available:
|
||||
prnt("", "[grey53]No binaries detected. Run [green]archivebox install[/green] to detect dependencies.[/grey53]")
|
||||
|
||||
# Show hint if no binaries are installed yet
|
||||
has_any_installed = Binary.objects.filter(machine=machine).exclude(abspath="").exists()
|
||||
if not has_any_installed:
|
||||
prnt()
|
||||
prnt("", "[grey53]Run [green]archivebox install[/green] to detect and install dependencies.[/grey53]")
|
||||
|
||||
except Exception as e:
|
||||
# Handle database errors gracefully (locked, missing, etc.)
|
||||
prnt()
|
||||
prnt("", f"[yellow]Warning: Could not query binaries from database: {e}[/yellow]")
|
||||
prnt("", "[grey53]Run [green]archivebox init[/green] and [green]archivebox install[/green] to set up dependencies.[/grey53]")
|
||||
if not any_rows:
|
||||
prnt("", "[grey53]No required binaries declared for discovered plugins.[/grey53]")
|
||||
elif not any_available:
|
||||
prnt("", "[grey53]No binaries detected. Run [green]archivebox install[/green] to detect dependencies.[/grey53]")
|
||||
|
||||
if not binaries:
|
||||
# Show code and data locations
|
||||
|
||||
+20
-12
@@ -250,10 +250,10 @@ class StorageConfig(BaseConfigSet):
|
||||
# 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, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
# LIB_DIR must be a local, fast, readable/writable dir by archivebox user,
|
||||
# ABXPKG_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, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
ABXPKG_LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_ABXPKG_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
OUTPUT_PERMISSIONS: str = Field(default="644")
|
||||
ENFORCE_ATOMIC_WRITES: bool = Field(default=True)
|
||||
@@ -536,6 +536,10 @@ def _archivebox_config_input_names() -> set[str]:
|
||||
for field in ArchiveBoxConfig.model_fields.values():
|
||||
if isinstance(field.alias, str):
|
||||
names.add(field.alias)
|
||||
for prop in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS).values():
|
||||
aliases = prop.get("x-aliases") if isinstance(prop, Mapping) else None
|
||||
if isinstance(aliases, list):
|
||||
names.update(str(alias) for alias in aliases if str(alias).strip())
|
||||
return names
|
||||
|
||||
|
||||
@@ -647,11 +651,10 @@ class ArchiveBoxBaseConfig(
|
||||
runtime_derived_keys = {
|
||||
"ABX_INSTALL_CACHE",
|
||||
"ACTIVE_PERSONA",
|
||||
"CHROME_DOWNLOADS_DIR",
|
||||
"CHROME_USER_DATA_DIR",
|
||||
"CRAWL_DIR",
|
||||
"DEFAULT_PERSONA",
|
||||
"EXTRA_CONTEXT",
|
||||
"PERSONAS_DIR",
|
||||
"SNAP_DIR",
|
||||
}
|
||||
return frozenset(
|
||||
@@ -697,9 +700,9 @@ class ArchiveBoxBaseConfig(
|
||||
config["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
|
||||
scope_by_key = type(self)._scope_by_key()
|
||||
model_fields = type(self).model_fields
|
||||
# ArchiveBox owns SEARCH_BACKEND_ENGINE and uses it during model
|
||||
# validation to derive the selected backend's *_ENABLED flag. Hooks
|
||||
# only receive the backend-local flags, never the selector itself.
|
||||
# ArchiveBox-only selectors are consumed during model validation to
|
||||
# derive hook-owned *_ENABLED flags. They are stripped below after
|
||||
# crawl/snapshot overlays have had a chance to apply.
|
||||
config.pop("SEARCH_BACKEND_ENGINE", None)
|
||||
if persona is not None:
|
||||
for key, value in persona.get_derived_config().items():
|
||||
@@ -721,6 +724,11 @@ class ArchiveBoxBaseConfig(
|
||||
if runtime_overrides:
|
||||
config.update(normalize_runtime_config(runtime_overrides, json_safe=False))
|
||||
|
||||
# Hooks should only see concrete plugin-local flags, never the
|
||||
# ArchiveBox selectors used to derive them.
|
||||
config.pop("PLUGINS", None)
|
||||
config.pop("SEARCH_BACKEND_ENGINE", None)
|
||||
|
||||
if extra_context:
|
||||
context: dict[str, Any] = {}
|
||||
if config.get("EXTRA_CONTEXT"):
|
||||
@@ -735,10 +743,10 @@ class ArchiveBoxBaseConfig(
|
||||
|
||||
@model_validator(mode="after")
|
||||
def resolve_runtime_paths(self):
|
||||
lib_dir = self.LIB_DIR.expanduser()
|
||||
lib_dir = self.ABXPKG_LIB_DIR.expanduser()
|
||||
if not lib_dir.is_absolute():
|
||||
lib_dir = CONSTANTS.DATA_DIR / lib_dir
|
||||
self.LIB_DIR = lib_dir.resolve()
|
||||
self.ABXPKG_LIB_DIR = lib_dir.resolve()
|
||||
|
||||
return self
|
||||
|
||||
@@ -997,6 +1005,7 @@ def get_config(
|
||||
persona = crawl.resolve_persona()
|
||||
|
||||
config_data: ConfigPayload = dict(defaults or {})
|
||||
config_data["PERSONAS_DIR"] = str(CONSTANTS.PERSONAS_DIR)
|
||||
base_config_payload: ConfigPayload = {}
|
||||
if crawl_config_base:
|
||||
config_data.update(
|
||||
@@ -1024,7 +1033,7 @@ def get_config(
|
||||
|
||||
scope_overrides.update(
|
||||
normalize_runtime_config(
|
||||
_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")),
|
||||
_sanitize_machine_config(machine.config, lib_dir=config_data.get("ABXPKG_LIB_DIR")),
|
||||
only_crawl_execution=crawl_config_base,
|
||||
exclude_runtime_derived=True,
|
||||
json_safe=False,
|
||||
@@ -1133,8 +1142,7 @@ def get_config(
|
||||
value = config[key]
|
||||
if is_sensitive_config_key(key) and value not in (None, ""):
|
||||
setattr(config, key, SENSITIVE_CONFIG_VALUE_REDACTED)
|
||||
os.environ["LIB_DIR"] = str(config.LIB_DIR)
|
||||
os.environ["ABXPKG_LIB_DIR"] = str(config.LIB_DIR)
|
||||
os.environ["ABXPKG_LIB_DIR"] = str(config.ABXPKG_LIB_DIR)
|
||||
archiving_warning_key = (config.TIMEOUT, config.USE_COLOR)
|
||||
if archiving_warning_key not in _WARNED_ARCHIVING_CONFIGS:
|
||||
config.warn_if_invalid()
|
||||
|
||||
@@ -113,10 +113,9 @@ class ConstantsDict:
|
||||
TMP_DIR_NAME: str = "tmp"
|
||||
DEFAULT_TMP_DIR: Path = DATA_DIR / TMP_DIR_NAME / MACHINE_ID # ./data/tmp/abc3244323
|
||||
|
||||
LIB_DIR_NAME: str = "lib"
|
||||
DEFAULT_LIB_DIR: Path = _env_path(
|
||||
"LIB_DIR",
|
||||
user_config_path("abx") / LIB_DIR_NAME,
|
||||
DEFAULT_ABXPKG_LIB_DIR: Path = _env_path(
|
||||
"ABXPKG_LIB_DIR",
|
||||
user_config_path("abx") / "lib",
|
||||
)
|
||||
|
||||
RESERVED_ARCHIVE_DIR_NAMES: frozenset[str] = frozenset(
|
||||
@@ -240,7 +239,7 @@ class ConstantsDict:
|
||||
SOURCES_DIR_NAME,
|
||||
LOGS_DIR_NAME,
|
||||
CACHE_DIR_NAME,
|
||||
LIB_DIR_NAME,
|
||||
"lib",
|
||||
TMP_DIR_NAME,
|
||||
PERSONAS_DIR_NAME,
|
||||
CUSTOM_TEMPLATES_DIR_NAME,
|
||||
|
||||
@@ -41,6 +41,12 @@ def setup_django(check_db=False, in_memory_db=False) -> None:
|
||||
# TODO: figure out why CLI entrypoints with init_pending are running this twice sometimes
|
||||
return
|
||||
|
||||
# SQLite creates index.sqlite3 during django.setup()/migrate. Apply the
|
||||
# ArchiveBox file-mode policy before any DB connection can create the file,
|
||||
# otherwise a permissive parent umask can expose a just-created DB until a
|
||||
# later chmod runs.
|
||||
os.umask(0o777 - (int(CONFIG.OUTPUT_PERMISSIONS, base=8) | 0o111))
|
||||
|
||||
# Third-party patches are only needed once Django/apps are about to load.
|
||||
# Keeping them out of archivebox.__init__ avoids paying Django/Daphne setup
|
||||
# cost for cheap CLI startup paths like `archivebox <cmd> --help`.
|
||||
|
||||
@@ -254,8 +254,8 @@ def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBox
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
|
||||
# LIB_DIR is either the shared platformdirs default or an explicit env/config override.
|
||||
CANDIDATES = [config.LIB_DIR]
|
||||
# ABXPKG_LIB_DIR is either the shared platformdirs default or an explicit env/config override.
|
||||
CANDIDATES = [config.ABXPKG_LIB_DIR]
|
||||
|
||||
for candidate in CANDIDATES:
|
||||
try:
|
||||
@@ -263,12 +263,12 @@ def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBox
|
||||
except Exception:
|
||||
pass
|
||||
if check_lib_dir(candidate, throw=False, quiet=True, must_exist=True):
|
||||
if autofix and config.LIB_DIR != candidate:
|
||||
os.environ["LIB_DIR"] = str(candidate)
|
||||
if autofix and config.ABXPKG_LIB_DIR != candidate:
|
||||
os.environ["ABXPKG_LIB_DIR"] = str(candidate)
|
||||
return candidate
|
||||
|
||||
if not quiet:
|
||||
raise OSError(f"ArchiveBox is unable to find a writable LIB_DIR, tried {CANDIDATES}!")
|
||||
raise OSError(f"ArchiveBox is unable to find a writable ABXPKG_LIB_DIR, tried {CANDIDATES}!")
|
||||
|
||||
|
||||
def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs):
|
||||
@@ -364,9 +364,9 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
try:
|
||||
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.LIB_DIR
|
||||
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.ABXPKG_LIB_DIR
|
||||
except Exception:
|
||||
lib_dir = config.LIB_DIR
|
||||
lib_dir = config.ABXPKG_LIB_DIR
|
||||
|
||||
return AttrDict(
|
||||
{
|
||||
@@ -390,7 +390,7 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
"enabled": os.path.isdir(CONSTANTS.USER_PLUGINS_DIR),
|
||||
"is_valid": os.path.isdir(CONSTANTS.USER_PLUGINS_DIR) and os.access(CONSTANTS.USER_PLUGINS_DIR, os.R_OK), # read
|
||||
},
|
||||
"LIB_DIR": {
|
||||
"ABXPKG_LIB_DIR": {
|
||||
"path": lib_dir.resolve(),
|
||||
"enabled": True,
|
||||
"is_valid": os.path.isdir(lib_dir) and os.access(lib_dir, os.R_OK) and os.access(lib_dir, os.W_OK), # read + write
|
||||
|
||||
@@ -49,10 +49,10 @@ def get_COMMIT_HASH() -> str | None:
|
||||
|
||||
if IN_DOCKER:
|
||||
try:
|
||||
version_txt = Path("/VERSION.txt").read_text()
|
||||
docker_commit_hashes = re.findall(r"COMMIT_HASH=([0-9a-fA-F]{40})", version_txt)
|
||||
if docker_commit_hashes:
|
||||
return docker_commit_hashes[-1]
|
||||
version_text = Path("/VERSION.txt").read_text()
|
||||
match = re.search(r"^COMMIT_HASH=([0-9a-fA-F]{40})$", version_text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -106,13 +106,10 @@ def get_COMMIT_HASH() -> str | None:
|
||||
|
||||
@cache
|
||||
def get_BUILD_TIME() -> str:
|
||||
if IN_DOCKER:
|
||||
try:
|
||||
# if we're in the archivebox official docker image, /VERSION.txt will contain the build time
|
||||
docker_build_end_time = Path("/VERSION.txt").read_text().rsplit("BUILD_END_TIME=")[-1].split("\n", 1)[0]
|
||||
return docker_build_end_time
|
||||
except Exception:
|
||||
pass
|
||||
for env_var in ("ARCHIVEBOX_BUILD_TIME", "BUILD_TIME"):
|
||||
build_time = os.environ.get(env_var, "").strip()
|
||||
if build_time:
|
||||
return build_time
|
||||
|
||||
src_last_modified_unix_timestamp = (PACKAGE_DIR / "README.md").stat().st_mtime
|
||||
return datetime.fromtimestamp(src_last_modified_unix_timestamp).strftime("%Y-%m-%d %H:%M:%S %s")
|
||||
|
||||
@@ -142,25 +142,31 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
process = result.process_record
|
||||
process_display = "-"
|
||||
if process:
|
||||
process_url = html.escape(reverse("admin:machine_process_change", args=[process.id]), quote=True)
|
||||
process_label = html.escape(get_process_link_label(process), quote=True)
|
||||
process_display = f'''
|
||||
<a href="{reverse("admin:machine_process_change", args=[process.id])}"
|
||||
<a href="{process_url}"
|
||||
style="color: #2563eb; text-decoration: none; font-family: ui-monospace, monospace; font-size: 12px;"
|
||||
title="View process">{get_process_link_label(process)}</a>
|
||||
title="View process">{process_label}</a>
|
||||
'''
|
||||
|
||||
machine_display = "-"
|
||||
if process and process.machine_id:
|
||||
machine_url = html.escape(reverse("admin:machine_machine_change", args=[process.machine_id]), quote=True)
|
||||
machine_label = html.escape(str(process.machine.hostname or ""), quote=True)
|
||||
machine_display = f'''
|
||||
<a href="{reverse("admin:machine_machine_change", args=[process.machine_id])}"
|
||||
<a href="{machine_url}"
|
||||
style="color: #2563eb; text-decoration: none; font-size: 12px;"
|
||||
title="View machine">{process.machine.hostname}</a>
|
||||
title="View machine">{machine_label}</a>
|
||||
'''
|
||||
|
||||
# Truncate output for display
|
||||
full_output = result.output_str_for_display() or "-"
|
||||
output_display = full_output[:60]
|
||||
if len(full_output) > 60:
|
||||
output_display += "..."
|
||||
full_output_raw = result.output_str_for_display() or "-"
|
||||
output_display_raw = full_output_raw[:60]
|
||||
if len(full_output_raw) > 60:
|
||||
output_display_raw += "..."
|
||||
full_output = html.escape(full_output_raw)
|
||||
output_display = html.escape(output_display_raw)
|
||||
|
||||
display_cmd = build_abx_dl_display_command(result)
|
||||
replay_cmd = build_abx_dl_replay_command(result, config=config)
|
||||
@@ -174,9 +180,13 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
|
||||
else:
|
||||
output_link = build_snapshot_url(snapshot_id, "", config=config)
|
||||
output_link_attr = html.escape(output_link, quote=True)
|
||||
|
||||
# Get version - try cmd_version field
|
||||
version = result.cmd_version if result.cmd_version else "-"
|
||||
version = html.escape(str(result.cmd_version if result.cmd_version else "-"), quote=True)
|
||||
plugin_text = html.escape(str(result.plugin or ""), quote=True)
|
||||
status_text = html.escape(str(status), quote=True)
|
||||
pwd_text = html.escape(str(result.pwd or "-"), quote=True)
|
||||
|
||||
# Unique ID for this row's expandable output
|
||||
row_id = f"output_{idx}_{str(result.id)[:8]}"
|
||||
@@ -193,18 +203,18 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
<td style="padding: 10px 12px; white-space: nowrap;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
color: {color}; background: {bg};">{status}</span>
|
||||
color: {color}; background: {bg};">{status_text}</span>
|
||||
</td>
|
||||
<td style="padding: 10px 12px; white-space: nowrap; font-size: 20px;" title="{result.plugin}">
|
||||
<td style="padding: 10px 12px; white-space: nowrap; font-size: 20px;" title="{plugin_text}">
|
||||
{icon}
|
||||
</td>
|
||||
<td style="padding: 10px 12px; font-weight: 500; color: #334155;">
|
||||
<a href="{output_link}" target="_blank"
|
||||
<a href="{output_link_attr}" target="_blank"
|
||||
style="color: #334155; text-decoration: none;"
|
||||
title="View output fullscreen"
|
||||
onmouseover="this.style.color='#2563eb'; this.style.textDecoration='underline';"
|
||||
onmouseout="this.style.color='#334155'; this.style.textDecoration='none';">
|
||||
{result.plugin}
|
||||
{plugin_text}
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 10px 12px; max-width: 280px;">
|
||||
@@ -231,7 +241,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
</td>
|
||||
<td style="padding: 10px 8px; white-space: nowrap;">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<a href="{output_link}" target="_blank"
|
||||
<a href="{output_link_attr}" target="_blank"
|
||||
style="padding: 4px 8px; background: #f1f5f9; border-radius: 4px; color: #475569; text-decoration: none; font-size: 11px;"
|
||||
title="View output">📄</a>
|
||||
<a href="{reverse("admin:core_archiveresult_change", args=[result.id])}"
|
||||
@@ -250,7 +260,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
<div style="font-size: 11px; color: #64748b; margin-bottom: 8px;">
|
||||
<span style="margin-right: 16px;"><b>ID:</b> <code>{str(result.id)}</code></span>
|
||||
<span style="margin-right: 16px;"><b>Version:</b> <code>{version}</code></span>
|
||||
<span style="margin-right: 16px;"><b>PWD:</b> <code>{result.pwd or "-"}</code></span>
|
||||
<span style="margin-right: 16px;"><b>PWD:</b> <code>{pwd_text}</code></span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64748b; margin-bottom: 8px;">
|
||||
<b>Output:</b>
|
||||
|
||||
@@ -3,7 +3,15 @@ __package__ = "archivebox.core"
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||
from django.contrib.auth.decorators import login_not_required
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.db import DatabaseError, connection
|
||||
from django.http import HttpResponseRedirect
|
||||
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,
|
||||
@@ -12,6 +20,7 @@ from admin_data_views.admin import (
|
||||
|
||||
from archivebox.config import VERSION
|
||||
from archivebox.config.version import get_COMMIT_HASH
|
||||
from archivebox.core.routes_util import is_allowed_archivebox_redirect_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
@@ -21,6 +30,17 @@ if TYPE_CHECKING:
|
||||
from admin_data_views.typing import AppDict
|
||||
|
||||
|
||||
class ArchiveBoxLoginView(LoginView):
|
||||
def get_redirect_url(self) -> str:
|
||||
redirect_to = self.request.POST.get(
|
||||
self.redirect_field_name,
|
||||
self.request.GET.get(self.redirect_field_name),
|
||||
)
|
||||
if is_allowed_archivebox_redirect_url(redirect_to, request=self.request):
|
||||
return redirect_to
|
||||
return ""
|
||||
|
||||
|
||||
class ArchiveBoxAdmin(admin.AdminSite):
|
||||
site_header = "ArchiveBox"
|
||||
index_title = "Admin Views"
|
||||
@@ -72,6 +92,34 @@ class ArchiveBoxAdmin(admin.AdminSite):
|
||||
def admin_data_index_view(self, request: "HttpRequest", **kwargs: Any) -> "TemplateResponse":
|
||||
return adv_admin_data_index_view(self, request, **kwargs)
|
||||
|
||||
@method_decorator(never_cache)
|
||||
@login_not_required
|
||||
def login(self, request: "HttpRequest", extra_context: dict[str, Any] | None = None) -> "TemplateResponse":
|
||||
if request.method == "GET" and self.has_permission(request):
|
||||
return HttpResponseRedirect(reverse("admin:index", current_app=self.name))
|
||||
|
||||
from django.contrib.admin.forms import AdminAuthenticationForm
|
||||
|
||||
context = {
|
||||
**self.each_context(request),
|
||||
"title": _("Log in"),
|
||||
"subtitle": None,
|
||||
"app_path": request.get_full_path(),
|
||||
"username": request.user.get_username(),
|
||||
}
|
||||
if REDIRECT_FIELD_NAME not in request.GET and REDIRECT_FIELD_NAME not in request.POST:
|
||||
context[REDIRECT_FIELD_NAME] = reverse("admin:index", current_app=self.name)
|
||||
context.update(extra_context or {})
|
||||
|
||||
index_path = reverse("admin:index", current_app=self.name)
|
||||
request.current_app = self.name
|
||||
return ArchiveBoxLoginView.as_view(
|
||||
extra_context=context,
|
||||
authentication_form=self.login_form or AdminAuthenticationForm,
|
||||
template_name=self.login_template or "admin/login.html",
|
||||
next_page=index_path,
|
||||
)(request)
|
||||
|
||||
def index(self, request: "HttpRequest", extra_context: dict[str, Any] | None = None) -> "TemplateResponse":
|
||||
response = super().index(request, extra_context)
|
||||
if connection.vendor != "sqlite":
|
||||
|
||||
@@ -940,38 +940,54 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
config = request.archivebox_config
|
||||
results = self._get_prefetched_results(obj)
|
||||
if results is not None:
|
||||
has_screenshot = any(r.plugin == "screenshot" for r in results)
|
||||
has_favicon = any(r.plugin == "favicon" for r in results)
|
||||
has_extension_screenshot = any(r.plugin == EXTENSION_SCREENSHOT_PLUGIN for r in results)
|
||||
results = [r for r in results if r.plugin in ("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")]
|
||||
else:
|
||||
available_plugins = set(
|
||||
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).values_list(
|
||||
results = list(
|
||||
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
flat=True,
|
||||
"status",
|
||||
"output_files",
|
||||
"output_str",
|
||||
),
|
||||
)
|
||||
has_screenshot = "screenshot" in available_plugins
|
||||
has_favicon = "favicon" in available_plugins
|
||||
has_extension_screenshot = EXTENSION_SCREENSHOT_PLUGIN in available_plugins
|
||||
|
||||
extension_screenshot_urls = [
|
||||
build_snapshot_url(str(obj.id), f"{EXTENSION_SCREENSHOT_PLUGIN}/screenshot-1.png", request=request, config=config),
|
||||
build_snapshot_url(str(obj.id), f"{EXTENSION_SCREENSHOT_PLUGIN}/screenshot.png", request=request, config=config),
|
||||
]
|
||||
def result_output_path(result, filename: str) -> str | None:
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
return None
|
||||
if file_info.get("root_relative"):
|
||||
return filename
|
||||
return f"{result.plugin}/{filename}"
|
||||
|
||||
if not has_screenshot and not has_extension_screenshot and not has_favicon:
|
||||
def result_urls(plugin: str, filenames: tuple[str, ...]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for result in results:
|
||||
if result.plugin != plugin or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
continue
|
||||
for filename in filenames:
|
||||
output_path = result_output_path(result, filename)
|
||||
if output_path:
|
||||
urls.append(build_snapshot_url(str(obj.id), output_path, request=request, config=config))
|
||||
return urls
|
||||
|
||||
screenshot_urls = result_urls("screenshot", ("screenshot.png",))
|
||||
extension_screenshot_urls = result_urls(EXTENSION_SCREENSHOT_PLUGIN, ("screenshot-1.png", "screenshot.png"))
|
||||
favicon_urls = result_urls("favicon", ("favicon.ico",))
|
||||
|
||||
if not screenshot_urls and not extension_screenshot_urls and not favicon_urls:
|
||||
return None
|
||||
|
||||
if has_screenshot or has_extension_screenshot:
|
||||
img_url = build_snapshot_url(str(obj.id), "screenshot/screenshot.png", request=request, config=config)
|
||||
fallbacks = extension_screenshot_urls
|
||||
all_screenshot_urls = [*screenshot_urls, *extension_screenshot_urls]
|
||||
if all_screenshot_urls:
|
||||
img_url = all_screenshot_urls[0]
|
||||
fallbacks = [*all_screenshot_urls[1:], *favicon_urls]
|
||||
img_alt = "Screenshot"
|
||||
preview_class = "screenshot"
|
||||
else:
|
||||
img_url = build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config)
|
||||
fallbacks = [
|
||||
build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config),
|
||||
]
|
||||
img_url = favicon_urls[0]
|
||||
fallbacks = favicon_urls[1:]
|
||||
img_alt = "Favicon"
|
||||
preview_class = "favicon"
|
||||
|
||||
@@ -989,6 +1005,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"preview_class": preview_class,
|
||||
"onerror_js": onerror_js,
|
||||
"fallback_list": fallback_list,
|
||||
"favicon_url": favicon_urls[0] if favicon_urls else "",
|
||||
"favicon_fallback_list": ",".join(favicon_urls[1:]),
|
||||
}
|
||||
|
||||
@admin.display(description="", empty_value="")
|
||||
@@ -997,10 +1015,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
if not preview:
|
||||
return ""
|
||||
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
favicon_url = build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config)
|
||||
fallback_list = ",".join([build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)])
|
||||
favicon_url = preview.get("favicon_url") or ""
|
||||
if not favicon_url:
|
||||
return ""
|
||||
fallback_list = preview.get("favicon_fallback_list") or ""
|
||||
onerror_js = (
|
||||
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
|
||||
"(this.src=this.dataset.fallbacks.split(',').shift(), "
|
||||
|
||||
+69
-11
@@ -6,11 +6,10 @@ from decimal import Decimal, InvalidOperation, ROUND_CEILING
|
||||
from django import forms
|
||||
|
||||
from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes
|
||||
from taggit.utils import edit_string_for_tags, parse_tags
|
||||
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, is_admin_user
|
||||
from archivebox.core.permissions import PERMISSIONS_CHOICES, PERMISSIONS_PUBLIC, filter_personas_by_permissions
|
||||
from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
|
||||
from archivebox.plugins.discovery import get_plugins
|
||||
from archivebox.plugins.forms import (
|
||||
@@ -30,13 +29,74 @@ DEPTH_CHOICES = (
|
||||
)
|
||||
|
||||
|
||||
def _split_strip(value: str, delimiter: str) -> list[str]:
|
||||
return [part.strip() for part in value.split(delimiter) if part.strip()]
|
||||
|
||||
|
||||
def parse_tag_string(value: str | None) -> list[str]:
|
||||
"""Parse the legacy tag-editing format used by the admin tag field."""
|
||||
if not value:
|
||||
return []
|
||||
|
||||
if "," not in value and '"' not in value:
|
||||
return sorted(set(_split_strip(value, " ")))
|
||||
|
||||
tags: list[str] = []
|
||||
buffer: list[str] = []
|
||||
deferred_chunks: list[str] = []
|
||||
saw_unquoted_comma = False
|
||||
in_quote = False
|
||||
chars = iter(value)
|
||||
|
||||
try:
|
||||
while True:
|
||||
char = next(chars)
|
||||
if char == '"':
|
||||
if buffer:
|
||||
deferred_chunks.append("".join(buffer))
|
||||
buffer = []
|
||||
in_quote = True
|
||||
char = next(chars)
|
||||
while char != '"':
|
||||
buffer.append(char)
|
||||
char = next(chars)
|
||||
tag = "".join(buffer).strip()
|
||||
if tag:
|
||||
tags.append(tag)
|
||||
buffer = []
|
||||
in_quote = False
|
||||
else:
|
||||
if not saw_unquoted_comma and char == ",":
|
||||
saw_unquoted_comma = True
|
||||
buffer.append(char)
|
||||
except StopIteration:
|
||||
if buffer:
|
||||
if in_quote and "," in buffer:
|
||||
saw_unquoted_comma = True
|
||||
deferred_chunks.append("".join(buffer))
|
||||
|
||||
delimiter = "," if saw_unquoted_comma else " "
|
||||
for chunk in deferred_chunks:
|
||||
tags.extend(_split_strip(chunk, delimiter))
|
||||
|
||||
return sorted(set(tags))
|
||||
|
||||
|
||||
def edit_string_for_tag_names(tags) -> str:
|
||||
names = []
|
||||
for tag in tags:
|
||||
name = tag.name
|
||||
names.append(f'"{name}"' if "," in name or " " in name else name)
|
||||
return ", ".join(sorted(names))
|
||||
|
||||
|
||||
class AddLinkForm(PluginConfigFormMixin, forms.Form):
|
||||
allow_crawl_execution_config_fields = False
|
||||
|
||||
# Basic fields
|
||||
url = forms.CharField(
|
||||
label="URLs",
|
||||
strip=True,
|
||||
strip=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"data-url-regex": URL_REGEX.pattern,
|
||||
@@ -239,7 +299,10 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop("request", None)
|
||||
self.can_override_crawl_config = bool(self.request and is_admin_user(self.request))
|
||||
self.can_override_crawl_config = False
|
||||
if self.request is not None:
|
||||
user = self.request.user
|
||||
self.can_override_crawl_config = bool(user.is_authenticated and user.is_active and user.is_superuser)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
default_persona = Persona.get_or_create_default()
|
||||
@@ -423,7 +486,7 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
|
||||
class TagWidget(forms.TextInput):
|
||||
def format_value(self, value):
|
||||
if value is not None and not isinstance(value, str):
|
||||
value = edit_string_for_tags(value)
|
||||
value = edit_string_for_tag_names(value)
|
||||
return super().format_value(value)
|
||||
|
||||
|
||||
@@ -432,12 +495,7 @@ class TagField(forms.CharField):
|
||||
|
||||
def clean(self, value):
|
||||
value = super().clean(value)
|
||||
try:
|
||||
return parse_tags(value)
|
||||
except ValueError:
|
||||
raise forms.ValidationError(
|
||||
"Please provide a comma-separated list of tags.",
|
||||
)
|
||||
return parse_tag_string(value)
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
# Always return False if the field is disabled since self.bound_data
|
||||
|
||||
+48
-14
@@ -36,7 +36,8 @@ from archivebox.misc.util import (
|
||||
to_json,
|
||||
ts_to_date_str,
|
||||
urlencode,
|
||||
htmlencode,
|
||||
htmldecode,
|
||||
sanitize_html_text,
|
||||
urldecode,
|
||||
validate_url,
|
||||
)
|
||||
@@ -94,6 +95,12 @@ class Tag(ModelWithUUID):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is None or "name" in update_fields:
|
||||
self.name = sanitize_html_text(self.name).strip()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
"""ASCII-safe slugified form of the tag name (derived, not stored)."""
|
||||
@@ -136,7 +143,7 @@ class Tag(ModelWithUUID):
|
||||
|
||||
# Auto-attach to snapshot if in overrides
|
||||
if overrides and "snapshot" in overrides and tag:
|
||||
overrides["snapshot"].tags.add(tag)
|
||||
overrides["snapshot"].add_tag_ids([tag.pk])
|
||||
|
||||
return tag
|
||||
|
||||
@@ -571,6 +578,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
@property
|
||||
def sm(self) -> "SnapshotMachine": ...
|
||||
|
||||
def add_tag_ids(self, tag_ids: Iterable[int | str]) -> None:
|
||||
tag_ids = [tag_id for tag_id in dict.fromkeys(tag_ids) if tag_id]
|
||||
if not tag_ids:
|
||||
return
|
||||
SnapshotTag.objects.bulk_create(
|
||||
[SnapshotTag(snapshot_id=self.pk, tag_id=tag_id) for tag_id in tag_ids],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
class Meta(
|
||||
ModelWithDeleteAfter.Meta,
|
||||
ModelWithOutputDir.Meta,
|
||||
@@ -938,6 +954,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
if self._state.adding or update_fields is None or "title" in update_fields:
|
||||
self.title = self._normalize_title_candidate(self.title, snapshot_url=self.url or "") or None
|
||||
if self._state.adding or update_fields is None or "notes" in update_fields:
|
||||
self.notes = sanitize_html_text(self.notes)
|
||||
|
||||
# Migrate filesystem if needed (happens automatically on save)
|
||||
existing_snapshot = self.pk and not self._state.adding
|
||||
@@ -962,6 +980,19 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
crawl = Crawl.objects.filter(pk=self.crawl_id).first()
|
||||
if crawl is None:
|
||||
return
|
||||
crawl_tag_names = crawl.current_tag_names()
|
||||
if crawl_tag_names:
|
||||
# Snapshots can be created by parser hook side-effect records,
|
||||
# direct ORM creates, or legacy crawl URL expansion. Crawl tags
|
||||
# are user-facing metadata on the whole import, so attach them
|
||||
# at the Snapshot.save() boundary instead of relying on every
|
||||
# caller to remember to duplicate this fanout logic.
|
||||
tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=crawl_tag_names)}
|
||||
missing_tags = [Tag(name=name) for name in crawl_tag_names if name not in tags_by_name]
|
||||
if missing_tags:
|
||||
Tag.objects.bulk_create(missing_tags, ignore_conflicts=True)
|
||||
tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=crawl_tag_names)}
|
||||
self.add_tag_ids([tag.pk for name in crawl_tag_names if (tag := tags_by_name.get(name))])
|
||||
# Snapshot.save() normally appends newly created URLs to Crawl.urls
|
||||
# so legacy/direct crawls can keep their queue text in sync. For
|
||||
# internal-input crawls that would corrupt the original submitted
|
||||
@@ -1677,7 +1708,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
with transaction.atomic():
|
||||
for tag_name in new_tags:
|
||||
tag, _ = Tag.objects.get_or_create(name=tag_name)
|
||||
self.tags.add(tag)
|
||||
self.add_tag_ids([tag.pk])
|
||||
|
||||
def _merge_archive_results_from_index(self, index_data: dict, update_existing: bool = True):
|
||||
"""Merge ArchiveResults one row per hook; retries update the existing row."""
|
||||
@@ -2123,7 +2154,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
# Merge tags
|
||||
for tag in dup.tags.all():
|
||||
keeper.tags.add(tag)
|
||||
keeper.add_tag_ids([tag.pk])
|
||||
|
||||
# Move ArchiveResults
|
||||
ArchiveResult.objects.filter(snapshot=dup).update(
|
||||
@@ -2296,7 +2327,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
@staticmethod
|
||||
def _normalize_title_candidate(candidate: str | None, *, snapshot_url: str) -> str:
|
||||
title = " ".join(line.strip() for line in str(candidate or "").splitlines() if line.strip()).strip()
|
||||
title = " ".join(line.strip() for line in sanitize_html_text(candidate).splitlines() if line.strip()).strip()
|
||||
if not title:
|
||||
return ""
|
||||
if title.lower() in {"pending...", "no title found", "unable to detect page title"}:
|
||||
@@ -2571,7 +2602,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
def save_tags(self, tags: Iterable[str] = ()) -> None:
|
||||
tags_id = [Tag.objects.get_or_create(name=tag)[0].pk for tag in tags if tag.strip()]
|
||||
self.tags.clear()
|
||||
self.tags.add(*tags_id)
|
||||
self.add_tag_ids(tags_id)
|
||||
|
||||
def pending_archiveresults(self) -> QuerySet["ArchiveResult"]:
|
||||
return self.archiveresult_set.exclude(status__in=ArchiveResult.FINAL_OR_ACTIVE_STATES)
|
||||
@@ -2752,15 +2783,18 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
)
|
||||
rprint(f"[red]⚠️ Snapshot.from_json auto-created new crawl {crawl.id} for url={url}[/red]", file=sys.stderr)
|
||||
|
||||
# Parse tags (accept either a list ["tag1", "tag2"] or a comma-separated string "tag1,tag2")
|
||||
# Parser hooks emit child Snapshot records through this generic
|
||||
# dispatcher after crawling an internal import root. Those child rows
|
||||
# must inherit crawl-level tags just like direct Crawl.urls snapshots;
|
||||
# otherwise `archivebox add --tag ... < bookmarks.html` loses the tag
|
||||
# unless the parser format also happened to provide its own tags.
|
||||
tags_raw = record.get("tags", "")
|
||||
tag_list = []
|
||||
tag_list = list(crawl.current_tag_names()) if crawl else []
|
||||
if isinstance(tags_raw, list):
|
||||
tag_list = list(dict.fromkeys(tag.strip() for tag in tags_raw if tag.strip()))
|
||||
tag_list.extend(tag.strip() for tag in tags_raw if tag.strip())
|
||||
elif tags_raw:
|
||||
tag_list = list(
|
||||
dict.fromkeys(tag.strip() for tag in re.split(config.TAG_SEPARATOR_PATTERN, tags_raw) if tag.strip()),
|
||||
)
|
||||
tag_list.extend(tag.strip() for tag in re.split(config.TAG_SEPARATOR_PATTERN, tags_raw) if tag.strip())
|
||||
tag_list = list(dict.fromkeys(tag_list))
|
||||
|
||||
# Check for existing snapshot with same URL in same crawl
|
||||
# (URLs can exist in multiple crawls, but should be unique within a crawl)
|
||||
@@ -3400,8 +3434,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
context = {
|
||||
**self.to_dict(extended=True),
|
||||
"snapshot": self,
|
||||
"title": htmlencode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
|
||||
"url_str": htmlencode(urldecode(self.base_url)),
|
||||
"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",
|
||||
"extension": self.extension or "html",
|
||||
"tags": self.tags_str() or "untagged",
|
||||
|
||||
@@ -10,7 +10,7 @@ def _is_signal_interrupted_exit(exit_code: int | None) -> bool:
|
||||
return exit_code is not None and (exit_code < 0 or exit_code >= 128)
|
||||
|
||||
|
||||
def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int]:
|
||||
def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str | None = None) -> dict[str, int]:
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.services.archive_result_service import _collect_output_metadata
|
||||
@@ -21,10 +21,13 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
|
||||
now = timezone.now()
|
||||
recovery_console = Console(stderr=True, highlight=False, soft_wrap=True)
|
||||
crawl_filter = {"id": crawl_id} if crawl_id else {}
|
||||
snapshot_filter = {"crawl_id": crawl_id} if crawl_id else {}
|
||||
result_filter = {"snapshot__crawl_id": crawl_id} if crawl_id else {}
|
||||
cleaned = {
|
||||
"processes_stale_running": Process.cleanup_stale_running(),
|
||||
"processes_orphaned_workers": Process.cleanup_orphaned_workers(),
|
||||
"chrome_processes_orphaned": Process.cleanup_orphaned_chrome() if include_chrome else 0,
|
||||
"processes_stale_running": 0 if crawl_id else Process.cleanup_stale_running(),
|
||||
"processes_orphaned_workers": 0 if crawl_id else Process.cleanup_orphaned_workers(),
|
||||
"chrome_processes_orphaned": Process.cleanup_orphaned_chrome() if include_chrome and not crawl_id else 0,
|
||||
"crawls_queued_without_retry_at": 0,
|
||||
"snapshots_queued_without_retry_at": 0,
|
||||
"archiveresults_backoff": 0,
|
||||
@@ -58,14 +61,16 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
cleaned["crawls_queued_without_retry_at"] = Crawl.objects.filter(
|
||||
status=Crawl.StatusChoices.QUEUED,
|
||||
retry_at__isnull=True,
|
||||
**crawl_filter,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
cleaned["snapshots_queued_without_retry_at"] = Snapshot.objects.filter(
|
||||
status=Snapshot.StatusChoices.QUEUED,
|
||||
retry_at__isnull=True,
|
||||
crawl__status__in=Crawl.RUNNABLE_STATES,
|
||||
**snapshot_filter,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF)
|
||||
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED).exclude(
|
||||
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF, **result_filter)
|
||||
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED, **result_filter).exclude(
|
||||
process__status=Process.StatusChoices.RUNNING,
|
||||
)
|
||||
# ArchiveResult has no retry_at scheduler. Wake only the parent Snapshots
|
||||
@@ -92,7 +97,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
# only after this runner has won the single-runner gate, so it can safely
|
||||
# unlock those stale plugin leases for immediate processing instead of
|
||||
# waiting out the previous owner's full lock timeout.
|
||||
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED)
|
||||
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED, **result_filter)
|
||||
cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
|
||||
Snapshot.objects.filter(
|
||||
id__in=queued_plugin_results.values("snapshot_id"),
|
||||
@@ -113,16 +118,33 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
process_type=Process.TypeChoices.HOOK,
|
||||
archiveresult__isnull=True,
|
||||
).exclude(status=Process.StatusChoices.RUNNING)
|
||||
crawl_snapshot_ids = (
|
||||
{str(snapshot_id) for snapshot_id in Snapshot.objects.filter(crawl_id=crawl_id).values_list("id", flat=True)} if crawl_id else None
|
||||
)
|
||||
if crawl_snapshot_ids is not None:
|
||||
# Targeted `archivebox run --crawl-id ...` is used by foreground add/update
|
||||
# commands and by resume of one existing crawl. It must still repair bad
|
||||
# hook state for that crawl, but it must not scan historical orphaned hook
|
||||
# rows from unrelated crawls before the first snapshot can run.
|
||||
if crawl_snapshot_ids:
|
||||
snapshot_pwd_filter = Q()
|
||||
for snapshot_id in crawl_snapshot_ids:
|
||||
snapshot_pwd_filter |= Q(pwd__contains=snapshot_id)
|
||||
orphaned_hook_processes = orphaned_hook_processes.filter(snapshot_pwd_filter)
|
||||
else:
|
||||
orphaned_hook_processes = orphaned_hook_processes.none()
|
||||
for process in orphaned_hook_processes.only("id", "pwd", "cmd", "process_type", "status"):
|
||||
hook_script_name = process.hook_script_name
|
||||
if not hook_script_name or not process.pwd:
|
||||
continue
|
||||
plugin_dir = Path(process.pwd)
|
||||
if crawl_snapshot_ids is not None and plugin_dir.parent.name not in crawl_snapshot_ids:
|
||||
continue
|
||||
try:
|
||||
# Old or synthetic hook Process rows can point at arbitrary paths.
|
||||
# Only paths whose parent directory is a valid Snapshot id can be
|
||||
# reconstructed into ArchiveResult rows.
|
||||
snapshot = Snapshot.objects.filter(id=plugin_dir.parent.name).first()
|
||||
snapshot = Snapshot.objects.filter(id=plugin_dir.parent.name, **snapshot_filter).first()
|
||||
except ValidationError:
|
||||
continue
|
||||
if snapshot is None:
|
||||
@@ -181,6 +203,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
|
||||
started_snapshots = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
**snapshot_filter,
|
||||
)
|
||||
|
||||
# Broken lock repair: STARTED + retry_at=NULL or retry_at in the future
|
||||
@@ -205,6 +228,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
# are already final.
|
||||
recoverable_started_crawls = Crawl.objects.filter(status=Crawl.StatusChoices.STARTED).filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
**crawl_filter,
|
||||
)
|
||||
|
||||
due_started_crawls = recoverable_started_crawls.annotate(has_due_child=Exists(due_child_snapshots)).filter(has_due_child=True)
|
||||
|
||||
@@ -4,6 +4,8 @@ import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
|
||||
@@ -284,6 +286,54 @@ def host_matches(request_host: str, target_host: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_allowed_archivebox_redirect_url(url: str | None, request=None, config: dict[str, Any] | None = None) -> bool:
|
||||
"""Allow login redirects to this host or to the explicit ArchiveBox BASE_URL family.
|
||||
|
||||
Django already rejects arbitrary absolute ``next=`` URLs for the current host.
|
||||
ArchiveBox also has legitimate cross-subdomain handoffs (admin/web/api/snap-*),
|
||||
but only when BASE_URL is pinned. Without an explicit BASE_URL we do not widen
|
||||
trust based on a request Host header that may be user supplied.
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
require_https = bool(request and request.is_secure())
|
||||
request_host = request.get_host() if request else ""
|
||||
if url_has_allowed_host_and_scheme(url=url, allowed_hosts={request_host}, require_https=require_https):
|
||||
return True
|
||||
|
||||
parsed = urlparse(url)
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return False
|
||||
if require_https and parsed.scheme != "https":
|
||||
return False
|
||||
if parsed.username or parsed.password:
|
||||
return False
|
||||
|
||||
config = config or get_config()
|
||||
base_url = _normalize_base_url(config.BASE_URL)
|
||||
if not base_url:
|
||||
return False
|
||||
|
||||
base_host, base_port = split_host_port(urlparse(base_url).netloc)
|
||||
if not base_host:
|
||||
return False
|
||||
|
||||
redirect_host = (parsed.hostname or "").lower()
|
||||
if redirect_host != base_host and not redirect_host.endswith(f".{base_host}"):
|
||||
return False
|
||||
|
||||
try:
|
||||
redirect_port = str(parsed.port) if parsed.port else None
|
||||
except ValueError:
|
||||
return False
|
||||
if base_port:
|
||||
return redirect_port == base_port
|
||||
return redirect_port is None
|
||||
|
||||
|
||||
def _scheme_from_request(request=None, config: dict[str, Any] | None = None) -> str:
|
||||
config = config or get_config()
|
||||
override = _normalize_base_url(config.BASE_URL)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.util import sanitize_html_text
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url
|
||||
from archivebox.core.models import Snapshot, SnapshotTag, Tag
|
||||
|
||||
@@ -33,7 +34,7 @@ TAG_HAS_SNAPSHOTS_CHOICES = (
|
||||
|
||||
|
||||
def normalize_tag_name(name: str) -> str:
|
||||
return (name or "").strip()
|
||||
return sanitize_html_text(name).strip()
|
||||
|
||||
|
||||
def normalize_tag_sort(sort: str = "created_desc") -> str:
|
||||
|
||||
@@ -509,17 +509,59 @@ def snapshot_url(context, snapshot, path: str = "") -> str:
|
||||
return build_snapshot_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
|
||||
snapshot_id = str(_snapshot_id(snapshot))
|
||||
url_cache = snapshot.__dict__.setdefault("_snapshot_archiveresult_url_cache", {})
|
||||
cache_key = (plugin, filename)
|
||||
if cache_key in url_cache:
|
||||
return url_cache[cache_key]
|
||||
|
||||
results = None
|
||||
if "_admin_archiveresults" in snapshot.__dict__:
|
||||
results = snapshot.__dict__["_admin_archiveresults"]
|
||||
elif "archiveresult_set" in snapshot.__dict__.get("_prefetched_objects_cache", {}):
|
||||
results = snapshot.archiveresult_set.all()
|
||||
elif "_snapshot_archiveresult_url_results" in snapshot.__dict__:
|
||||
results = snapshot.__dict__["_snapshot_archiveresult_url_results"]
|
||||
|
||||
if results is None:
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
results = list(
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id=snapshot_id,
|
||||
plugin__in=("screenshot", "chrome_extension_screenshot", "favicon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"plugin",
|
||||
"status",
|
||||
"output_files",
|
||||
),
|
||||
)
|
||||
snapshot.__dict__["_snapshot_archiveresult_url_results"] = results
|
||||
|
||||
for result in results:
|
||||
if result.plugin != plugin or result.status != "succeeded":
|
||||
continue
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
continue
|
||||
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
|
||||
url_cache[cache_key] = build_snapshot_url(snapshot_id, output_path, request=context.get("request"), config=context.get("CONFIG"))
|
||||
return url_cache[cache_key]
|
||||
|
||||
url_cache[cache_key] = ""
|
||||
return ""
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_index_row(context, link) -> str:
|
||||
snapshot_id = str(_snapshot_id(link))
|
||||
request = context.get("request")
|
||||
config = context.get("CONFIG")
|
||||
snapshot_base = get_snapshot_base_url(snapshot_id, request=request, config=config)
|
||||
screenshot_plugin_url = f"{snapshot_base}/screenshot/screenshot.png"
|
||||
extension_screenshot_1_url = f"{snapshot_base}/chrome_extension_screenshot/screenshot-1.png"
|
||||
extension_screenshot_url = f"{snapshot_base}/chrome_extension_screenshot/screenshot.png"
|
||||
favicon_plugin_url = f"{snapshot_base}/favicon/favicon.ico"
|
||||
favicon_root_url = f"{snapshot_base}/favicon.ico"
|
||||
|
||||
status = getattr(link, "status", None) or "unknown"
|
||||
bookmarked_at = getattr(link, "bookmarked_at", None)
|
||||
@@ -560,11 +602,59 @@ def snapshot_index_row(context, link) -> str:
|
||||
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
|
||||
"</span>"
|
||||
)
|
||||
elif "_public_preview_paths" in link.__dict__:
|
||||
preview_paths = list(getattr(link, "_public_preview_paths", []) or [])
|
||||
if preview_paths:
|
||||
preview_urls = [build_snapshot_url(snapshot_id, path, request=request, config=config) for path in preview_paths]
|
||||
preview_html = (
|
||||
f'<img src="{escape(preview_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
|
||||
else:
|
||||
preview_html = (
|
||||
f'<img src="{escape(screenshot_plugin_url)}" '
|
||||
f'data-fallbacks="{escape(extension_screenshot_1_url)},{escape(extension_screenshot_url)}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
preview_urls = [
|
||||
url
|
||||
for url in (
|
||||
snapshot_archiveresult_url(context, link, "screenshot", "screenshot.png"),
|
||||
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot-1.png"),
|
||||
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot.png"),
|
||||
)
|
||||
if url
|
||||
]
|
||||
if preview_urls:
|
||||
preview_html = (
|
||||
f'<img src="{escape(preview_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
|
||||
|
||||
if "_public_favicon_paths" in link.__dict__:
|
||||
favicon_paths = list(getattr(link, "_public_favicon_paths", []) or [])
|
||||
if favicon_paths:
|
||||
favicon_urls = [build_snapshot_url(snapshot_id, path, request=request, config=config) for path in favicon_paths]
|
||||
favicon_html = (
|
||||
f'<img src="{escape(favicon_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(favicon_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" '
|
||||
'class="link-favicon" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
favicon_html = '<span class="link-favicon link-favicon-empty" aria-hidden="true"></span>'
|
||||
else:
|
||||
favicon_url = snapshot_archiveresult_url(context, link, "favicon", "favicon.ico")
|
||||
favicon_html = (
|
||||
(
|
||||
f'<img src="{escape(favicon_url)}" '
|
||||
'data-fallbacks="" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" '
|
||||
'class="link-favicon" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
if favicon_url
|
||||
else '<span class="link-favicon link-favicon-empty" aria-hidden="true"></span>'
|
||||
)
|
||||
|
||||
html = f"""
|
||||
@@ -583,13 +673,7 @@ def snapshot_index_row(context, link) -> str:
|
||||
<td class="snapshot-title-cell" title="{escape(title or url)}">
|
||||
<div class="snapshot-title-line">
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-favicon-link" title="Open archived snapshot">
|
||||
<img src="{escape(favicon_plugin_url)}"
|
||||
data-fallbacks="{escape(favicon_root_url)}"
|
||||
onerror="nextPublicSnapshotPreview(this)"
|
||||
class="link-favicon"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy">
|
||||
{favicon_html}
|
||||
</a>
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-title">
|
||||
{escape(Truncator(title_text).chars(110))}
|
||||
|
||||
+220
-78
@@ -11,7 +11,7 @@ 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
|
||||
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
|
||||
@@ -51,9 +51,11 @@ from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.misc.util import (
|
||||
base_url,
|
||||
filter_queryset_by_uuid_substring,
|
||||
htmlencode,
|
||||
htmldecode,
|
||||
sanitize_html_text,
|
||||
ts_to_date_str,
|
||||
urldecode,
|
||||
validate_url,
|
||||
without_fragment,
|
||||
)
|
||||
from archivebox.misc.serve_static import serve_static_with_byterange_support
|
||||
@@ -439,7 +441,7 @@ class SnapshotView(View):
|
||||
"progress_endpoint": progress_endpoint("snapshot", snapshot.id),
|
||||
"url": snapshot.url,
|
||||
"archive_path": snapshot.archive_path_from_db,
|
||||
"title": htmlencode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
|
||||
"title": htmldecode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
|
||||
"extension": snapshot.extension or "html",
|
||||
"tags": snapshot.tags_str() or "untagged",
|
||||
"size": printable_filesize(output_size) if output_size else "—",
|
||||
@@ -569,37 +571,43 @@ class SnapshotView(View):
|
||||
# Snapshot dir exists but file within does not e.g. 124235.324234/screenshot.png
|
||||
return HttpResponse(
|
||||
format_html(
|
||||
(
|
||||
"<html><head>"
|
||||
"<title>Snapshot Not Found</title>"
|
||||
#'<script>'
|
||||
#'setTimeout(() => { window.location.reload(); }, 5000);'
|
||||
#'</script>'
|
||||
"</head><body>"
|
||||
"<center><br/><br/><br/>"
|
||||
f'Snapshot <a href="/{snapshot.archive_path}/index.html" target="_top"><b><code>[{snapshot.timestamp}]</code></b></a>: <a href="{snapshot.url}" target="_blank" rel="noreferrer">{snapshot.url}</a><br/>'
|
||||
f"was queued on {str(snapshot.bookmarked_at).split('.')[0]}, "
|
||||
f'but no files have been saved yet in:<br/><b><a href="/{snapshot.archive_path}/" target="_top"><code>{snapshot.timestamp}</code></a><code>/'
|
||||
"{}"
|
||||
f"</code></b><br/><br/>"
|
||||
"It's possible {} "
|
||||
f"during the last capture on {str(snapshot.bookmarked_at).split('.')[0]},<br/>or that the archiving process has not completed yet.<br/>"
|
||||
f"<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>"
|
||||
f'<code style="user-select: all; color: #333">archivebox update -t timestamp {snapshot.timestamp}</code></pre><br/><br/>'
|
||||
'<div class="text-align: left; width: 100%; max-width: 400px">'
|
||||
"<i><b>Next steps:</i></b><br/>"
|
||||
f'- list all the <a href="/{snapshot.archive_path}/" target="_top">Snapshot files <code>.*</code></a><br/>'
|
||||
f'- view the <a href="/{snapshot.archive_path}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>'
|
||||
f'- go to the <a href="/admin/core/snapshot/{snapshot.pk}/change/" target="_top">Snapshot admin</a> to edit<br/>'
|
||||
f'- go to the <a href="/admin/core/snapshot/?id__exact={snapshot.id}" target="_top">Snapshot actions</a> to re-archive<br/>'
|
||||
'- or return to <a href="/" target="_top">the main index...</a></div>'
|
||||
"</center>"
|
||||
"</body></html>"
|
||||
),
|
||||
"""
|
||||
<html><head>
|
||||
<title>Snapshot Not Found</title>
|
||||
</head><body>
|
||||
<center><br/><br/><br/>
|
||||
Snapshot <a href="/{}/index.html" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
|
||||
was queued on {}, but no files have been saved yet in:<br/><b><a href="/{}/" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
|
||||
It's possible {} during the last capture on {},<br/>or that the archiving process has not completed yet.<br/>
|
||||
<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>
|
||||
<code style="user-select: all; color: #333">archivebox update -t timestamp {}</code></pre><br/><br/>
|
||||
<div class="text-align: left; width: 100%; max-width: 400px">
|
||||
<i><b>Next steps:</i></b><br/>
|
||||
- list all the <a href="/{}/" target="_top">Snapshot files <code>.*</code></a><br/>
|
||||
- view the <a href="/{}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>
|
||||
- go to the <a href="/admin/core/snapshot/{}/change/" target="_top">Snapshot admin</a> to edit<br/>
|
||||
- go to the <a href="/admin/core/snapshot/?id__exact={}" target="_top">Snapshot actions</a> to re-archive<br/>
|
||||
- or return to <a href="/" target="_top">the main index...</a></div>
|
||||
</center>
|
||||
</body></html>
|
||||
""",
|
||||
snapshot.archive_path,
|
||||
snapshot.timestamp,
|
||||
snapshot.url,
|
||||
snapshot.url,
|
||||
str(snapshot.bookmarked_at).split(".")[0],
|
||||
snapshot.archive_path,
|
||||
snapshot.timestamp,
|
||||
archivefile if str(archivefile) != "None" else "",
|
||||
f"the {archivefile} resource could not be fetched"
|
||||
if str(archivefile) != "None"
|
||||
else "the original site was not available",
|
||||
str(snapshot.bookmarked_at).split(".")[0],
|
||||
snapshot.timestamp,
|
||||
snapshot.archive_path,
|
||||
snapshot.archive_path,
|
||||
snapshot.pk,
|
||||
snapshot.id,
|
||||
),
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
@@ -812,6 +820,34 @@ def _safe_archive_relpath(path: str) -> str | None:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, bool]:
|
||||
"""Resolve plugin-relative output paths through ArchiveResult.output_files."""
|
||||
parts = Path(rel_path).parts
|
||||
if len(parts) < 2:
|
||||
return rel_path, False
|
||||
|
||||
plugin = parts[0]
|
||||
plugin_relpath = posixpath.join(*parts[1:])
|
||||
result = (
|
||||
ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED)
|
||||
.only("output_files")
|
||||
.first()
|
||||
)
|
||||
if not result or not result.output_files:
|
||||
return rel_path, False
|
||||
|
||||
output_files = result.output_files or {}
|
||||
for candidate in (plugin_relpath, rel_path):
|
||||
file_info = output_files.get(candidate)
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if file_info.get("root_relative"):
|
||||
return candidate, True
|
||||
return rel_path, True
|
||||
|
||||
return rel_path, True
|
||||
|
||||
|
||||
def _coerce_sort_timestamp(value: str | float | None) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
@@ -1005,6 +1041,8 @@ 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)
|
||||
|
||||
try:
|
||||
return serve_static_with_byterange_support(
|
||||
request,
|
||||
@@ -1218,6 +1256,8 @@ class PublicIndexView(ListView):
|
||||
snapshots = list(context.get("object_list") or ())
|
||||
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
|
||||
tag_names_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
preview_paths_by_snapshot: dict[str, list[tuple[int, int, str]]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
favicon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
progress_by_snapshot: dict[str, dict[str, int]] = {
|
||||
str(snapshot.id): {
|
||||
"total": 0,
|
||||
@@ -1238,20 +1278,51 @@ class PublicIndexView(ListView):
|
||||
):
|
||||
tag_names_by_snapshot[str(snapshot_id)].append(tag_name)
|
||||
|
||||
for snapshot_id, plugin, status in (
|
||||
preview_plugin_order = {
|
||||
"screenshot": 0,
|
||||
"chrome_extension_screenshot": 1,
|
||||
}
|
||||
preview_candidates = {
|
||||
"screenshot": ("screenshot.png",),
|
||||
"chrome_extension_screenshot": ("screenshot-1.png", "screenshot.png"),
|
||||
}
|
||||
|
||||
def result_output_path(result: ArchiveResult, filename: str) -> str | None:
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
return None
|
||||
if file_info.get("root_relative"):
|
||||
return filename
|
||||
return f"{result.plugin}/{filename}"
|
||||
|
||||
for result in (
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=icons_by_snapshot.keys(),
|
||||
)
|
||||
.exclude(plugin="")
|
||||
.values_list("snapshot_id", "plugin", "status")
|
||||
.only("snapshot_id", "plugin", "status", "output_files", "output_str")
|
||||
.iterator(chunk_size=1000)
|
||||
):
|
||||
snapshot_id = result.snapshot_id
|
||||
plugin = result.plugin
|
||||
status = result.status
|
||||
snapshot_key = str(snapshot_id)
|
||||
progress = progress_by_snapshot[snapshot_key]
|
||||
progress["total"] += 1
|
||||
if status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
icons_by_snapshot[snapshot_key].add(plugin)
|
||||
progress["succeeded"] += 1
|
||||
if plugin in preview_candidates:
|
||||
plugin_rank = preview_plugin_order[plugin]
|
||||
for filename_rank, filename in enumerate(preview_candidates[plugin]):
|
||||
output_path = result_output_path(result, filename)
|
||||
if output_path:
|
||||
preview_paths_by_snapshot[snapshot_key].append((plugin_rank, filename_rank, output_path))
|
||||
elif plugin == "favicon":
|
||||
output_path = result_output_path(result, "favicon.ico")
|
||||
if output_path:
|
||||
favicon_paths_by_snapshot[snapshot_key].append(output_path)
|
||||
elif status == ArchiveResult.StatusChoices.FAILED:
|
||||
progress["failed"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.STARTED:
|
||||
@@ -1267,6 +1338,10 @@ class PublicIndexView(ListView):
|
||||
snapshot._icons_progress_stats = progress_by_snapshot.get(str(snapshot.id), {})
|
||||
snapshot.num_outputs_cached = snapshot._icons_progress_stats.get("succeeded", 0)
|
||||
snapshot._tags_str_cached = ",".join(tag_names_by_snapshot.get(str(snapshot.id), []))
|
||||
snapshot._public_preview_paths = [
|
||||
output_path for _plugin_rank, _filename_rank, output_path in sorted(preview_paths_by_snapshot.get(str(snapshot.id), []))
|
||||
]
|
||||
snapshot._public_favicon_paths = favicon_paths_by_snapshot.get(str(snapshot.id), [])
|
||||
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
|
||||
context["object_list"] = snapshots
|
||||
return context
|
||||
@@ -1330,7 +1405,8 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
return get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
|
||||
|
||||
def _can_override_crawl_config(self) -> bool:
|
||||
return is_admin_user(self.request)
|
||||
user = self.request.user
|
||||
return bool(user.is_authenticated and user.is_active and user.is_superuser)
|
||||
|
||||
def _get_custom_config_overrides(self, form: AddLinkForm) -> dict:
|
||||
custom_config = form.cleaned_data.get("config") or {}
|
||||
@@ -1407,8 +1483,20 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
def _create_crawl_from_form(self, form, *, created_by_id=None) -> Crawl:
|
||||
from archivebox.cli.archivebox_add import add
|
||||
|
||||
urls = form.cleaned_data["url"]
|
||||
print(f"[+] Adding URL: {urls}")
|
||||
urls_input = form.cleaned_data["url"]
|
||||
urls = urls_input
|
||||
submitted_lines = [line.strip() for line in urls_input.splitlines() if line.strip()]
|
||||
if len(submitted_lines) == 1:
|
||||
try:
|
||||
# A lone URL pasted into /add/ is the same user-facing input as
|
||||
# `archivebox add https://...`: queue that URL directly so a
|
||||
# narrow plugin selection like `wget` can archive it without
|
||||
# also needing parser plugins. Multi-line or formatted text
|
||||
# remains verbatim import content for the internal parser root.
|
||||
urls = [validate_url(submitted_lines[0])]
|
||||
except ValueError:
|
||||
pass
|
||||
print(f"[+] Adding URL: {urls_input}")
|
||||
|
||||
# Extract all form fields
|
||||
tag = form.cleaned_data["tag"]
|
||||
@@ -1453,6 +1541,8 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
config["DELETE_AFTER"] = delete_after
|
||||
if timeout is not None and int(timeout) != int(effective_config.TIMEOUT):
|
||||
config["TIMEOUT"] = int(timeout)
|
||||
if permissions:
|
||||
config["PERMISSIONS"] = permissions
|
||||
|
||||
config.update(plugin_config)
|
||||
config.update(custom_config)
|
||||
@@ -1476,7 +1566,7 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
config=config,
|
||||
)
|
||||
if notes:
|
||||
crawl.safe_update({"notes": notes}, refresh=False)
|
||||
crawl.safe_update({"notes": sanitize_html_text(notes)}, refresh=False)
|
||||
if permissions and crawl.config.get("PERMISSIONS") != permissions:
|
||||
next_config = {**crawl.config, "PERMISSIONS": permissions}
|
||||
crawl.safe_update({"config": next_config}, refresh=True)
|
||||
@@ -1508,15 +1598,17 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
schedule = form.cleaned_data.get("schedule", "").strip()
|
||||
rough_url_count = len([url for url in urls.splitlines() if url.strip()])
|
||||
|
||||
# Build success message with schedule link if created
|
||||
schedule_msg = ""
|
||||
if schedule and crawl.schedule_id:
|
||||
schedule_msg = f" and <a href='{crawl.schedule.admin_change_url}'>scheduled to repeat {schedule}</a>"
|
||||
schedule_msg = format_html(" and <a href='{}'>scheduled to repeat {}</a>", crawl.schedule.admin_change_url, schedule)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
mark_safe(
|
||||
f"Created crawl with {rough_url_count} starting URL(s){schedule_msg}. Snapshots will be created and archived in the background. <a href='{crawl.admin_change_url}'>View Crawl →</a>",
|
||||
format_html(
|
||||
"Created crawl with {} starting URL(s){}. Snapshots will be created and archived in the background. <a href='{}'>View Crawl →</a>",
|
||||
rough_url_count,
|
||||
schedule_msg,
|
||||
crawl.admin_change_url,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1654,7 +1746,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
|
||||
# Use merged config value (includes machine overrides)
|
||||
actual_value = merged_config.get(key, dict(section)[key])
|
||||
rows["Value"].append(mark_safe(f"<code>{actual_value}</code>"))
|
||||
rows["Value"].append(format_html("<code>{}</code>", actual_value))
|
||||
|
||||
# Show where the value comes from
|
||||
source = find_config_source(key, merged_config)
|
||||
@@ -1662,8 +1754,10 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
rows["Source"].append(format_html('<code style="color: {}">{}</code>', source_colors.get(source, "gray"), source))
|
||||
|
||||
rows["Default"].append(
|
||||
mark_safe(
|
||||
f'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{key}&type=code"><code style="text-decoration: underline">{find_config_default(key) or "See here..."}</code></a>',
|
||||
format_html(
|
||||
'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{}&type=code"><code style="text-decoration: underline">{}</code></a>',
|
||||
key,
|
||||
find_config_default(key) or "See here...",
|
||||
),
|
||||
)
|
||||
# rows['Documentation'].append(mark_safe(f'Wiki: <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">{key}</a>'))
|
||||
@@ -1677,8 +1771,10 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
rows["Value"].append(format_html("<code>{}</code>", redact_sensitive_config(CONSTANTS_CONFIG).get(key)))
|
||||
rows["Source"].append(mark_safe('<code style="color: gray">Constant</code>'))
|
||||
rows["Default"].append(
|
||||
mark_safe(
|
||||
f'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{key}&type=code"><code style="text-decoration: underline">{find_config_default(key) or "See here..."}</code></a>',
|
||||
format_html(
|
||||
'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{}&type=code"><code style="text-decoration: underline">{}</code></a>',
|
||||
key,
|
||||
find_config_default(key) or "See here...",
|
||||
),
|
||||
)
|
||||
# rows['Documentation'].append(mark_safe(f'Wiki: <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">{key}</a>'))
|
||||
@@ -1732,25 +1828,56 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
|
||||
is_redacted = final_value == SENSITIVE_CONFIG_VALUE_REDACTED
|
||||
|
||||
# Build sources display
|
||||
sources_html = "<br/>".join([f'<b style="color: {color}">{source}:</b> <code>{value}</code>' for source, value, color in sources_info])
|
||||
sources_html = format_html_join(
|
||||
mark_safe("<br/>"),
|
||||
'<b style="color: {}">{}:</b> <code>{}</code>',
|
||||
((color, source, value) for source, value, color in sources_info),
|
||||
)
|
||||
|
||||
# aliases = USER_CONFIG.get(key, {}).get("aliases", [])
|
||||
aliases = []
|
||||
|
||||
if key in CONSTANTS_CONFIG:
|
||||
section_header = mark_safe(
|
||||
f'[CONSTANTS] <b><code style="color: lightgray">{key}</code></b> <small>(read-only, hardcoded by ArchiveBox)</small>',
|
||||
section_header = format_html(
|
||||
'[CONSTANTS] <b><code style="color: lightgray">{}</code></b> <small>(read-only, hardcoded by ArchiveBox)</small>',
|
||||
key,
|
||||
)
|
||||
elif key in merged_config:
|
||||
section_header = mark_safe(
|
||||
f'data / ArchiveBox.conf [{find_config_section(key)}] <b><code style="color: lightgray">{key}</code></b>',
|
||||
section_header = format_html(
|
||||
'data / ArchiveBox.conf [{}] <b><code style="color: lightgray">{}</code></b>',
|
||||
find_config_section(key),
|
||||
key,
|
||||
)
|
||||
else:
|
||||
section_header = mark_safe(
|
||||
f'[DYNAMIC CONFIG] <b><code style="color: lightgray">{key}</code></b> <small>(read-only, calculated at runtime)</small>',
|
||||
section_header = format_html(
|
||||
'[DYNAMIC CONFIG] <b><code style="color: lightgray">{}</code></b> <small>(read-only, calculated at runtime)</small>',
|
||||
key,
|
||||
)
|
||||
|
||||
definition_url, definition_label = get_config_definition_link(key)
|
||||
redacted_message = (
|
||||
mark_safe(
|
||||
'<b style="color: red">Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)</b><br/><br/>',
|
||||
)
|
||||
if is_redacted
|
||||
else ""
|
||||
)
|
||||
default_command_value = val.strip("'") if (val := find_config_default(key)) else str(final_value).strip("'")
|
||||
machine_config_link = (
|
||||
format_html('<br/><a href="{}">→ Edit <code>{}</code> in Machine.config for this server</a>', machine_admin_url, key)
|
||||
if machine_admin_url
|
||||
else ""
|
||||
)
|
||||
machine_config_tip = (
|
||||
format_html(
|
||||
'<br/><b>Tip:</b> To override <code>{}</code> on this machine, <a href="{}">edit the Machine.config field</a> and add:<br/><code>{}</code>',
|
||||
key,
|
||||
machine_admin_url,
|
||||
f'{{"{key}": "your_value_here"}}',
|
||||
)
|
||||
if machine_admin_url and key not in CONSTANTS_CONFIG
|
||||
else ""
|
||||
)
|
||||
|
||||
section_data = cast(
|
||||
SectionData,
|
||||
@@ -1764,50 +1891,65 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
|
||||
"Currently read from": config_source,
|
||||
},
|
||||
"help_texts": {
|
||||
"Key": mark_safe(f"""
|
||||
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">Documentation</a>
|
||||
<span style="display: {"inline" if aliases else "none"}">
|
||||
Aliases: {", ".join(aliases)}
|
||||
"Key": format_html(
|
||||
"""
|
||||
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{}">Documentation</a>
|
||||
<span style="display: {}">
|
||||
Aliases: {}
|
||||
</span>
|
||||
"""),
|
||||
"Type": mark_safe(f'''
|
||||
<a href="{definition_url}" target="_blank" rel="noopener noreferrer">
|
||||
See full definition in <code>{definition_label}</code>...
|
||||
""",
|
||||
key.lower(),
|
||||
"inline" if aliases else "none",
|
||||
", ".join(aliases),
|
||||
),
|
||||
"Type": format_html(
|
||||
"""
|
||||
<a href="{}" target="_blank" rel="noopener noreferrer">
|
||||
See full definition in <code>{}</code>...
|
||||
</a>
|
||||
'''),
|
||||
"Value": mark_safe(f'''
|
||||
{
|
||||
'<b style="color: red">Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)</b><br/><br/>'
|
||||
if is_redacted
|
||||
else ""
|
||||
}
|
||||
""",
|
||||
definition_url,
|
||||
definition_label,
|
||||
),
|
||||
"Value": format_html(
|
||||
"""
|
||||
{}
|
||||
<br/><hr/><br/>
|
||||
<b>Configuration Sources (highest priority first):</b><br/><br/>
|
||||
{sources_html}
|
||||
{}
|
||||
<br/><br/>
|
||||
<p style="display: {"block" if key in merged_config and key not in CONSTANTS_CONFIG else "none"}">
|
||||
<p style="display: {}">
|
||||
<i>To change this value, edit <code>data/ArchiveBox.conf</code> or run:</i>
|
||||
<br/><br/>
|
||||
<code>archivebox config --set {key}="{
|
||||
val.strip("'") if (val := find_config_default(key)) else str(final_value).strip("'")
|
||||
}"</code>
|
||||
<code>archivebox config --set {}="{}"</code>
|
||||
</p>
|
||||
'''),
|
||||
"Currently read from": mark_safe(f"""
|
||||
The value shown in the "Value" field comes from the <b>{config_source}</b> source.
|
||||
""",
|
||||
redacted_message,
|
||||
sources_html,
|
||||
"block" if key in merged_config and key not in CONSTANTS_CONFIG else "none",
|
||||
key,
|
||||
default_command_value,
|
||||
),
|
||||
"Currently read from": format_html(
|
||||
"""
|
||||
The value shown in the "Value" field comes from the <b>{}</b> source.
|
||||
<br/><br/>
|
||||
Priority order (highest to lowest):
|
||||
<ol>
|
||||
<li><b style="color: purple">Machine</b> - Machine-specific overrides
|
||||
{f'<br/><a href="{machine_admin_url}">→ Edit <code>{key}</code> in Machine.config for this server</a>' if machine_admin_url else ""}
|
||||
{}
|
||||
</li>
|
||||
<li><b style="color: blue">Environment</b> - process defaults from environment variables</li>
|
||||
<li><b style="color: green">File</b> - data/ArchiveBox.conf</li>
|
||||
<li><b style="color: gray">Plugin Default</b> - Default value from plugin config.json</li>
|
||||
<li><b style="color: gray">Default</b> - Default value from code</li>
|
||||
</ol>
|
||||
{f'<br/><b>Tip:</b> To override <code>{key}</code> on this machine, <a href="{machine_admin_url}">edit the Machine.config field</a> and add:<br/><code>{{"\\"{key}\\": "your_value_here"}}</code>' if machine_admin_url and key not in CONSTANTS_CONFIG else ""}
|
||||
"""),
|
||||
{}
|
||||
""",
|
||||
config_source,
|
||||
machine_config_link,
|
||||
machine_config_tip,
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -40,6 +40,16 @@ class TagEditorWidget(forms.Widget):
|
||||
normalized = f"t_{normalized}"
|
||||
return normalized
|
||||
|
||||
def _json_for_inline_script(self, value):
|
||||
"""Serialize JSON so it cannot close the surrounding inline <script> tag."""
|
||||
return json.dumps(value).translate(
|
||||
{
|
||||
ord(">"): "\\u003E",
|
||||
ord("<"): "\\u003C",
|
||||
ord("&"): "\\u0026",
|
||||
},
|
||||
)
|
||||
|
||||
def _tag_style(self, value):
|
||||
"""Compute a stable pastel color style for a tag value."""
|
||||
tag = (value or "").strip().lower()
|
||||
@@ -106,6 +116,8 @@ class TagEditorWidget(forms.Widget):
|
||||
</span>
|
||||
'''
|
||||
|
||||
tags_json = self._json_for_inline_script(tags)
|
||||
|
||||
# Build the widget HTML
|
||||
html = f'''
|
||||
<div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
|
||||
@@ -128,7 +140,7 @@ class TagEditorWidget(forms.Widget):
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
var currentTags_{widget_id} = {json.dumps(tags)};
|
||||
var currentTags_{widget_id} = {tags_json};
|
||||
var autocompleteTimeout_{widget_id} = null;
|
||||
|
||||
window.focusTagInput_{widget_id} = function(event) {{
|
||||
|
||||
+30
-17
@@ -2,7 +2,6 @@ __package__ = "archivebox.crawls"
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Iterable, Mapping
|
||||
from io import StringIO
|
||||
import uuid
|
||||
import json
|
||||
import re
|
||||
@@ -35,7 +34,7 @@ from archivebox.base_models.models import (
|
||||
)
|
||||
from archivebox.workers.models import RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine
|
||||
from archivebox.crawls.schedule_util import next_run_for_schedule, validate_schedule
|
||||
from archivebox.misc.util import parse_date, validate_url, validate_url_length
|
||||
from archivebox.misc.util import parse_date, sanitize_html_text, validate_url, validate_url_length
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from archivebox.core.models import Snapshot
|
||||
@@ -70,9 +69,14 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
|
||||
return str(reverse_lazy("api-1:get_any", args=[self.id]))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is None or "label" in update_fields:
|
||||
self.label = sanitize_html_text(self.label).strip()
|
||||
if update_fields is None or "notes" in update_fields:
|
||||
self.notes = sanitize_html_text(self.notes)
|
||||
self.schedule = (self.schedule or "").strip()
|
||||
validate_schedule(self.schedule)
|
||||
self.label = self.label or (self.template.label if self.template else "")
|
||||
self.label = self.label or (sanitize_html_text(self.template.label).strip() if self.template else "")
|
||||
super().save(*args, **kwargs)
|
||||
if self.template:
|
||||
self.template.safe_update(
|
||||
@@ -280,6 +284,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is None or "label" in update_fields:
|
||||
self.label = sanitize_html_text(self.label).strip()
|
||||
if update_fields is None or "notes" in update_fields:
|
||||
self.notes = sanitize_html_text(self.notes)
|
||||
if update_fields is None or "tags_str" in update_fields:
|
||||
self.tags_str = ",".join(self.parse_tag_names(self.tags_str or ""))
|
||||
sync_tags = update_fields is None or "tags_str" in update_fields
|
||||
old_crawl = type(self).objects.filter(pk=self.pk).first() if self.pk else None
|
||||
previous_tag_names = set()
|
||||
@@ -376,7 +386,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
tag_names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_tag in raw_tags:
|
||||
tag_name = str(raw_tag or "").strip()
|
||||
tag_name = sanitize_html_text(raw_tag).strip()
|
||||
if not tag_name:
|
||||
continue
|
||||
lowered = tag_name.lower()
|
||||
@@ -503,12 +513,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
date_str = self.created_at.strftime("%Y%m%d")
|
||||
first_url = ""
|
||||
for raw_line in StringIO(self.urls or ""):
|
||||
candidate = raw_line.strip()
|
||||
if candidate and not candidate.startswith("#"):
|
||||
first_url = candidate
|
||||
break
|
||||
first_url = next((url for url in self.get_urls_list() if url), "")
|
||||
domain = Snapshot.extract_domain_from_url(first_url) if first_url else "unknown"
|
||||
|
||||
output_dir = CONSTANTS.USERS_DIR / self.created_by.username / CONSTANTS.CRAWLS_DIR_NAME / date_str / domain / str(self.id)
|
||||
@@ -519,14 +524,14 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
"""Get list of URLs from urls field, filtering out comments and empty lines."""
|
||||
if not self.urls:
|
||||
return []
|
||||
return [url.strip() for url in self.urls.split("\n") if url.strip() and not url.strip().startswith("#")]
|
||||
return [url for _raw_line, url in self._iter_url_lines() if url]
|
||||
|
||||
def has_internal_input_root(self) -> bool:
|
||||
"""Return True when Crawl.urls is preserved source text, not the work queue.
|
||||
|
||||
`archivebox add` creates a synthetic root snapshot to run parser hooks
|
||||
through the same Snapshot lifecycle as every other extractor. In that
|
||||
mode the raw submitted import text must remain in Crawl.urls forever;
|
||||
The runner creates a synthetic root snapshot for raw import text so
|
||||
parser hooks use the same Snapshot lifecycle as every other extractor.
|
||||
In that mode the submitted text must remain in Crawl.urls forever;
|
||||
parsed URLs live as child Snapshot rows and should not be appended back.
|
||||
"""
|
||||
from archivebox.core.models import Snapshot
|
||||
@@ -701,7 +706,14 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(stripped)
|
||||
entries.append((raw_line.rstrip(), str(entry.get("url", "") or "").strip()))
|
||||
# Crawl.urls accepts plain lines and JSONL URL records. Other
|
||||
# valid JSON values, e.g. a quoted string from hostile input,
|
||||
# are not records and must stay inert text instead of raising
|
||||
# during later Snapshot.save() bookkeeping.
|
||||
if isinstance(entry, dict):
|
||||
entries.append((raw_line.rstrip(), str(entry.get("url", "") or "").strip()))
|
||||
else:
|
||||
entries.append((raw_line.rstrip(), stripped))
|
||||
except json.JSONDecodeError:
|
||||
entries.append((raw_line.rstrip(), stripped))
|
||||
return entries
|
||||
@@ -1082,7 +1094,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
# relation without clearing any non-crawl snapshot tags.
|
||||
Tag.objects.bulk_create(missing_tags, ignore_conflicts=True)
|
||||
tags_by_name.update({tag.name: tag for tag in Tag.objects.filter(name__in=missing_names)})
|
||||
snapshot.tags.add(*[tag.pk for tag_name in tag_names if (tag := tags_by_name.get(tag_name))])
|
||||
snapshot.add_tag_ids([tag.pk for tag_name in tag_names if (tag := tags_by_name.get(tag_name))])
|
||||
|
||||
# Symlink creation touches the filesystem and can be slow on remote disks.
|
||||
# Defer it until after any active DB transaction commits so SQLite does
|
||||
@@ -1183,6 +1195,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
if update_fields:
|
||||
snapshot.save(update_fields=[*update_fields, "modified_at"])
|
||||
tag_names = {
|
||||
*crawl_tag_names,
|
||||
*self.parse_tag_names(
|
||||
str(record.get("tags") or ""),
|
||||
pattern=self._config_value(config, "TAG_SEPARATOR_PATTERN", r"[,]"),
|
||||
@@ -1190,7 +1203,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
}
|
||||
if tag_names:
|
||||
tag_ids = [Tag.objects.get_or_create(name=tag_name)[0].pk for tag_name in tag_names]
|
||||
snapshot.tags.add(*tag_ids)
|
||||
snapshot.add_tag_ids(tag_ids)
|
||||
|
||||
existing_scope = Snapshot.objects if bool(self._config_value(config, "ONLY_NEW", True)) else self.snapshot_set
|
||||
existing_urls = set(existing_scope.filter(url__in=deduped_records.keys()).values_list("url", flat=True))
|
||||
|
||||
@@ -140,7 +140,7 @@ def _sanitize_machine_config(config: dict[str, Any] | None, *, lib_dir: str | Pa
|
||||
"""Validate ``Machine.config`` in place.
|
||||
|
||||
Drops stale ``*_BINARY`` overrides whose path no longer exists or whose
|
||||
path falls outside of ``LIB_DIR`` (so a binary uninstall or a lib_dir
|
||||
path falls outside of ``ABXPKG_LIB_DIR`` (so a binary uninstall or a lib_dir
|
||||
move clears the override automatically). Non-``_BINARY`` keys
|
||||
(``BASE_URL``, ``SERVER_SECURITY_MODE``, plugin tunables, etc.) are
|
||||
pass-through — they're arbitrary config overrides and not ours to filter.
|
||||
@@ -287,7 +287,7 @@ class Machine(ModelWithHealthStats):
|
||||
def _sanitize_config(cls, machine: Machine) -> Machine:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
sanitized = _sanitize_machine_config(machine.config, lib_dir=get_config(include_machine=False).LIB_DIR)
|
||||
sanitized = _sanitize_machine_config(machine.config, lib_dir=get_config(include_machine=False).ABXPKG_LIB_DIR)
|
||||
current = machine.config or {}
|
||||
if sanitized != current:
|
||||
machine.config = sanitized
|
||||
@@ -681,7 +681,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
)
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().LIB_DIR / "bin")
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().ABXPKG_LIB_DIR / "bin")
|
||||
return binary
|
||||
|
||||
# Case 2: From binaries.json - create queued binary (needs installation)
|
||||
@@ -714,7 +714,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
)
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().LIB_DIR / "bin")
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().ABXPKG_LIB_DIR / "bin")
|
||||
return binary
|
||||
|
||||
return None
|
||||
@@ -754,7 +754,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
|
||||
After a binary is installed by any binprovider (pip, npm, brew, apt, etc),
|
||||
we can optionally expose a flat convenience directory for shell users.
|
||||
ArchiveBox/abx-dl runtime lookup must use the provider-specific LIB_DIR
|
||||
ArchiveBox/abx-dl runtime lookup must use the provider-specific ABXPKG_LIB_DIR
|
||||
paths, not this indirection.
|
||||
|
||||
Args:
|
||||
|
||||
+12
-10
@@ -273,9 +273,9 @@ def check_data_dir_permissions(config=None, **config_kwargs):
|
||||
tmp_dir = config.TMP_DIR
|
||||
|
||||
try:
|
||||
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.LIB_DIR
|
||||
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.ABXPKG_LIB_DIR
|
||||
except Exception:
|
||||
lib_dir = config.LIB_DIR
|
||||
lib_dir = config.ABXPKG_LIB_DIR
|
||||
|
||||
# Check /tmp dir permissions
|
||||
check_tmp_dir(tmp_dir, throw=False, must_exist=True, config=config)
|
||||
@@ -365,7 +365,7 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
lib_dir = lib_dir or config.LIB_DIR
|
||||
lib_dir = lib_dir or config.ABXPKG_LIB_DIR
|
||||
|
||||
if not must_exist and not os.path.isdir(lib_dir):
|
||||
return True
|
||||
@@ -373,7 +373,7 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
|
||||
lib_is_valid = False
|
||||
try:
|
||||
lib_is_valid = dir_is_writable(lib_dir)
|
||||
assert lib_is_valid, f"ArchiveBox user {ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP} is unable to write to LIB_DIR={lib_dir}"
|
||||
assert lib_is_valid, f"ArchiveBox user {ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP} is unable to write to ABXPKG_LIB_DIR={lib_dir}"
|
||||
return True
|
||||
except Exception as e:
|
||||
if not quiet:
|
||||
@@ -381,16 +381,16 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
|
||||
ERROR_TEXT = "\n".join(
|
||||
(
|
||||
"",
|
||||
f"[red]:cross_mark: ArchiveBox is unable to use LIB_DIR={pretty_path(lib_dir)}[/red]",
|
||||
f"[red]:cross_mark: ArchiveBox is unable to use ABXPKG_LIB_DIR={pretty_path(lib_dir)}[/red]",
|
||||
f" [yellow]{e}[/yellow]",
|
||||
"",
|
||||
"[blue]Info:[/blue] [grey53]The LIB_DIR is used to store ArchiveBox auto-installed plugin library and binary dependencies.",
|
||||
"[blue]Info:[/blue] [grey53]The ABXPKG_LIB_DIR is used to store ArchiveBox auto-installed plugin library and binary dependencies.",
|
||||
f" - It [red]must[/red] be readable and writable by the ArchiveBox user ({ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}).",
|
||||
" - It [yellow]should[/yellow] be on a local (ideally fast) drive like an SSD or HDD (not on a network drive or external HDD).",
|
||||
" - It [yellow]should[/yellow] be able to hold at least 1GB of data (some dependencies like Chrome can be large).[/grey53]",
|
||||
"",
|
||||
"[violet]Hint:[/violet] Fix it by setting LIB_DIR to a path that meets these requirements, e.g.:",
|
||||
f" [green]archivebox config --set LIB_DIR={get_or_create_working_lib_dir(autofix=False, quiet=True) or config.LIB_DIR}[/green]",
|
||||
"[violet]Hint:[/violet] Fix it by setting ABXPKG_LIB_DIR to a path that meets these requirements, e.g.:",
|
||||
f" [green]archivebox config --set ABXPKG_LIB_DIR={get_or_create_working_lib_dir(autofix=False, quiet=True) or config.ABXPKG_LIB_DIR}[/green]",
|
||||
"",
|
||||
),
|
||||
)
|
||||
@@ -399,11 +399,13 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
|
||||
ERROR_TEXT,
|
||||
expand=False,
|
||||
border_style="red",
|
||||
title="[red]:cross_mark: Error with configured LIB_DIR[/red]",
|
||||
title="[red]:cross_mark: Error with configured ABXPKG_LIB_DIR[/red]",
|
||||
subtitle="[yellow]Dependencies may not auto-install properly until fixed.[/yellow]",
|
||||
),
|
||||
)
|
||||
STDERR.print()
|
||||
if throw:
|
||||
raise OSError(f"LIB_DIR={lib_dir} is invalid, ArchiveBox is unable to use it and dependencies will fail to install.") from e
|
||||
raise OSError(
|
||||
f"ABXPKG_LIB_DIR={lib_dir} is invalid, ArchiveBox is unable to use it and dependencies will fail to install.",
|
||||
) from e
|
||||
return False
|
||||
@@ -66,6 +66,22 @@ htmlencode = lambda s: s and escape(s, quote=True)
|
||||
htmldecode = lambda s: s and unescape(s)
|
||||
|
||||
|
||||
def sanitize_html_text(value: Any) -> str:
|
||||
"""Strip all HTML from user-editable text before storing it."""
|
||||
if value is None:
|
||||
return ""
|
||||
import bleach
|
||||
|
||||
return bleach.clean(
|
||||
str(value),
|
||||
tags=[],
|
||||
attributes={},
|
||||
protocols=[],
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
|
||||
|
||||
def ts_to_date_str(ts: Any) -> str | None:
|
||||
parsed = parse_date(ts)
|
||||
return None if parsed is None else parsed.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
@@ -47,6 +47,12 @@ VOLATILE_PROFILE_COPY_PATTERNS = (
|
||||
"ShaderCache",
|
||||
"Service Worker",
|
||||
"GCM Store",
|
||||
"chrome-extension_*",
|
||||
"DNR Extension Rules",
|
||||
"Extension Rules",
|
||||
"Extension Scripts",
|
||||
"Extension State",
|
||||
"Local Extension Settings",
|
||||
"*.log",
|
||||
"Crashpad",
|
||||
"BrowserMetrics",
|
||||
@@ -611,7 +617,7 @@ def export_browser_state(
|
||||
if not state_script.exists():
|
||||
return False, None, f"Browser state export script not found at {state_script}"
|
||||
|
||||
node_modules_dir = get_config().LIB_DIR / "pnpm" / "packages" / "chrome" / "node_modules"
|
||||
node_modules_dir = get_config().ABXPKG_LIB_DIR / "pnpm" / "packages" / "chrome" / "node_modules"
|
||||
chrome_plugin_dir = Path(get_plugins_dir()).resolve()
|
||||
|
||||
env = os.environ.copy()
|
||||
|
||||
@@ -11,14 +11,12 @@ Each persona has its own:
|
||||
__package__ = "archivebox.personas"
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Mapping
|
||||
|
||||
from django.db import models
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models.fields.json import KT
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -62,11 +60,7 @@ VOLATILE_PROFILE_FILE_NAMES = {
|
||||
|
||||
def derive_persona_config(*, name: str, config: Mapping[str, Any] | None, persona_dir: Path) -> dict[str, Any]:
|
||||
derived = dict(config or {})
|
||||
|
||||
if "CHROME_USER_DATA_DIR" not in derived:
|
||||
derived["CHROME_USER_DATA_DIR"] = str(persona_dir / "chrome_profile")
|
||||
if "CHROME_DOWNLOADS_DIR" not in derived:
|
||||
derived["CHROME_DOWNLOADS_DIR"] = str(persona_dir / "chrome_downloads")
|
||||
derived["PERSONAS_DIR"] = str(persona_dir.parent)
|
||||
|
||||
cookies_path = persona_dir / "cookies.txt"
|
||||
if "COOKIES_FILE" not in derived and cookies_path.exists():
|
||||
@@ -165,8 +159,7 @@ class Persona(ModelWithConfig):
|
||||
|
||||
Returns dict with:
|
||||
- All values from self.config JSONField
|
||||
- CHROME_USER_DATA_DIR (derived from persona path)
|
||||
- CHROME_DOWNLOADS_DIR (derived from persona path)
|
||||
- PERSONAS_DIR (derived from DATA_DIR/personas)
|
||||
- COOKIES_FILE (derived from persona path, if file exists)
|
||||
- AUTH_STORAGE_FILE (derived from persona path, if file exists)
|
||||
- ACTIVE_PERSONA (set to this persona's name)
|
||||
@@ -228,6 +221,18 @@ class Persona(ModelWithConfig):
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
@classmethod
|
||||
def get_or_create_named(cls, name: str) -> "Persona":
|
||||
persona_name = (name or "Default").strip() or "Default"
|
||||
persona = cls.objects.filter(name=persona_name).first()
|
||||
if persona is not None:
|
||||
return persona
|
||||
|
||||
try:
|
||||
return cls.objects.create(name=persona_name)
|
||||
except IntegrityError:
|
||||
return cls.objects.get(name=persona_name)
|
||||
|
||||
def runtime_root_for_crawl(self, crawl) -> Path:
|
||||
return Path(crawl.output_dir) / ".persona" / self.name
|
||||
|
||||
@@ -247,27 +252,16 @@ class Persona(ModelWithConfig):
|
||||
return self.runtime_root_for_snapshot(snapshot) / "chrome_downloads"
|
||||
|
||||
def copy_chrome_profile(self, source_dir: Path, destination_dir: Path) -> None:
|
||||
from archivebox.personas.importers import VOLATILE_PROFILE_COPY_PATTERNS
|
||||
|
||||
destination_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(destination_dir, ignore_errors=True)
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copy_cmd: list[str] | None = None
|
||||
source_contents = f"{source_dir}/."
|
||||
|
||||
if sys.platform == "darwin":
|
||||
copy_cmd = ["cp", "-cR", source_contents, str(destination_dir)]
|
||||
elif sys.platform.startswith("linux"):
|
||||
copy_cmd = ["cp", "-a", source_contents, str(destination_dir)]
|
||||
|
||||
if copy_cmd:
|
||||
result = subprocess.run(copy_cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
|
||||
shutil.rmtree(destination_dir, ignore_errors=True)
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.copytree(source_dir, destination_dir, symlinks=True, dirs_exist_ok=True)
|
||||
shutil.copytree(
|
||||
source_dir,
|
||||
destination_dir,
|
||||
symlinks=True,
|
||||
ignore=shutil.ignore_patterns(*VOLATILE_PROFILE_COPY_PATTERNS),
|
||||
)
|
||||
|
||||
def prepare_runtime_for_crawl(self, crawl, chrome_binary: str = "") -> dict[str, str]:
|
||||
self.ensure_dirs()
|
||||
@@ -294,8 +288,13 @@ class Persona(ModelWithConfig):
|
||||
(runtime_root / "chrome_binary.txt").write_text(chrome_binary)
|
||||
|
||||
return {
|
||||
"CHROME_USER_DATA_DIR": str(runtime_profile_dir),
|
||||
"CHROME_DOWNLOADS_DIR": str(runtime_downloads_dir),
|
||||
# Hooks derive CHROME_USER_DATA_DIR/CHROME_DOWNLOADS_DIR from
|
||||
# PERSONAS_DIR + ACTIVE_PERSONA. Point PERSONAS_DIR at the
|
||||
# per-crawl runtime root here so CHROME_ISOLATION=crawl never
|
||||
# leaks or reuses the template profile while keeping Chrome path
|
||||
# derivation centralized in the Chrome plugin helpers.
|
||||
"PERSONAS_DIR": str(runtime_root.parent),
|
||||
"ACTIVE_PERSONA": self.name,
|
||||
}
|
||||
|
||||
def prepare_runtime_for_snapshot(self, snapshot, chrome_binary: str = "") -> dict[str, str]:
|
||||
@@ -321,8 +320,11 @@ class Persona(ModelWithConfig):
|
||||
(runtime_root / "chrome_binary.txt").write_text(chrome_binary)
|
||||
|
||||
return {
|
||||
"CHROME_USER_DATA_DIR": str(runtime_profile_dir),
|
||||
"CHROME_DOWNLOADS_DIR": str(runtime_downloads_dir),
|
||||
# See prepare_runtime_for_crawl(): snapshot isolation changes the
|
||||
# persona root, not individual CHROME_* config keys, so standalone
|
||||
# Chrome hooks and ArchiveBox-driven hooks resolve paths the same way.
|
||||
"PERSONAS_DIR": str(runtime_root.parent),
|
||||
"ACTIVE_PERSONA": self.name,
|
||||
}
|
||||
|
||||
def cleanup_runtime_for_crawl(self, crawl) -> None:
|
||||
@@ -331,8 +333,7 @@ class Persona(ModelWithConfig):
|
||||
@classmethod
|
||||
def get_or_create_default(cls) -> "Persona":
|
||||
"""Get or create the Default persona."""
|
||||
persona, _ = cls.objects.get_or_create(name="Default")
|
||||
return persona
|
||||
return cls.get_or_create_named("Default")
|
||||
|
||||
@classmethod
|
||||
def cleanup_chrome_all(cls) -> int:
|
||||
|
||||
@@ -279,13 +279,13 @@ def run_hook(
|
||||
records = process.get_records() # Get parsed JSONL output
|
||||
"""
|
||||
from archivebox.machine.models import Process, Machine, NetworkInterface
|
||||
from archivebox.config.common import get_config, normalize_runtime_config
|
||||
from archivebox.config.common import ArchiveBoxConfig, _archivebox_config_input_names, get_config, normalize_runtime_config
|
||||
|
||||
config_scope = {key.removeprefix("config_"): kwargs.pop(key) for key in list(kwargs) if key.startswith("config_")}
|
||||
config_overrides = _config_to_overrides(config)
|
||||
resolved_config = get_config(overrides=config_overrides, **config_scope)
|
||||
hook_config = normalize_runtime_config(
|
||||
config_overrides if config is not None else resolved_config.for_crawl(),
|
||||
resolved_config.for_crawl_runtime(),
|
||||
json_safe=False,
|
||||
)
|
||||
|
||||
@@ -365,6 +365,9 @@ def run_hook(
|
||||
|
||||
# Set up environment with base paths
|
||||
env = os.environ.copy()
|
||||
archivebox_config_input_names = _archivebox_config_input_names()
|
||||
for key in archivebox_config_input_names:
|
||||
env.pop(key, None)
|
||||
env["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
|
||||
env["LIBRARY_VERSION"] = VERSION
|
||||
env.setdefault("MACHINE_ID", os.environ.get("MACHINE_ID", CONSTANTS.MACHINE_ID))
|
||||
@@ -375,10 +378,9 @@ def run_hook(
|
||||
if crawl_dir:
|
||||
env["CRAWL_DIR"] = str(crawl_dir)
|
||||
|
||||
# Export runtime library roots; abx-dl/abxpkg own executable lookup env.
|
||||
lib_dir = hook_config.get("LIB_DIR")
|
||||
# Export the runtime library root; abx-dl/abxpkg own executable lookup env.
|
||||
lib_dir = hook_config.get("ABXPKG_LIB_DIR")
|
||||
if lib_dir:
|
||||
env["LIB_DIR"] = str(lib_dir)
|
||||
env["ABXPKG_LIB_DIR"] = str(lib_dir)
|
||||
|
||||
# Set Node.js module resolution paths.
|
||||
@@ -400,10 +402,9 @@ def run_hook(
|
||||
env["NODE_PATH"] = os.pathsep.join(node_path_parts)
|
||||
|
||||
# Export all config values to environment (already merged by get_config())
|
||||
# Skip keys we've already handled specially above (PATH, LIB_DIR, NODE_PATH, etc.)
|
||||
# Skip keys we've already handled specially above (PATH, ABXPKG_LIB_DIR, NODE_PATH, etc.)
|
||||
SKIP_KEYS = {
|
||||
"PATH",
|
||||
"LIB_DIR",
|
||||
"ABXPKG_LIB_DIR",
|
||||
"NODE_PATH",
|
||||
"NODE_MODULES_DIR",
|
||||
@@ -413,9 +414,12 @@ def run_hook(
|
||||
"SNAP_DIR",
|
||||
"CRAWL_DIR",
|
||||
}
|
||||
canonical_config_keys = set(ArchiveBoxConfig.model_fields)
|
||||
for key, value in hook_config.items():
|
||||
if key in SKIP_KEYS:
|
||||
continue # Already handled specially above, don't overwrite
|
||||
if key in archivebox_config_input_names and key not in canonical_config_keys:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
elif isinstance(value, bool):
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import CharField, Count, Q, Sum
|
||||
from django.db.models.functions import Cast
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
@@ -935,27 +936,26 @@ def live_progress_view(request):
|
||||
except ImportError:
|
||||
return JsonResponse(payload)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_payload = {
|
||||
"error": str(e),
|
||||
"orchestrator_running": False,
|
||||
"total_workers": 0,
|
||||
"crawls_active": 0,
|
||||
"crawls_queued": 0,
|
||||
"crawls_recent": 0,
|
||||
"snapshots_active": 0,
|
||||
"snapshots_queued": 0,
|
||||
"archiveresults_active": 0,
|
||||
"archiveresults_queued": 0,
|
||||
"downloads_active": 0,
|
||||
"downloads_queued": 0,
|
||||
"indexing_active": 0,
|
||||
"indexing_queued": 0,
|
||||
"active_crawls": [],
|
||||
"server_time": timezone.now().isoformat(),
|
||||
}
|
||||
if settings.DEBUG:
|
||||
import traceback
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc(),
|
||||
"orchestrator_running": False,
|
||||
"total_workers": 0,
|
||||
"crawls_active": 0,
|
||||
"crawls_queued": 0,
|
||||
"crawls_recent": 0,
|
||||
"snapshots_active": 0,
|
||||
"snapshots_queued": 0,
|
||||
"archiveresults_active": 0,
|
||||
"archiveresults_queued": 0,
|
||||
"downloads_active": 0,
|
||||
"downloads_queued": 0,
|
||||
"indexing_active": 0,
|
||||
"indexing_queued": 0,
|
||||
"active_crawls": [],
|
||||
"server_time": timezone.now().isoformat(),
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
error_payload["traceback"] = traceback.format_exc()
|
||||
return JsonResponse(error_payload, status=500)
|
||||
@@ -237,12 +237,12 @@ def _should_update_snapshot_title(current_title: str, next_title: str, *, snapsh
|
||||
return len(next_title) > len(current)
|
||||
|
||||
|
||||
def _has_content_files(output_files: Any) -> bool:
|
||||
return any(Path(path).suffix not in {".log", ".pid", ".sh"} for path in _normalize_output_files(output_files))
|
||||
|
||||
|
||||
def _is_signal_interrupted_exit(exit_code: int) -> bool:
|
||||
return exit_code < 0 or (exit_code >= 128 and exit_code != PROCESS_EXIT_SKIPPED)
|
||||
def _status_for_process_without_archive_result(event: ProcessCompletedEvent) -> str:
|
||||
if event.exit_code == PROCESS_EXIT_SKIPPED:
|
||||
return "skipped"
|
||||
if event.exit_code != 0:
|
||||
return "failed"
|
||||
return "noresults"
|
||||
|
||||
|
||||
def _iter_archiveresult_records(stdout: str) -> list[dict]:
|
||||
@@ -444,24 +444,14 @@ class ArchiveResultService(BaseService):
|
||||
).now()
|
||||
return
|
||||
|
||||
# TODO: consider moving this fallback derivation into abx-dl itself.
|
||||
# First try both patterns: if the whole abx-dl process crashes, restarting
|
||||
# the snapshot may be enough, but don't guess before validating it.
|
||||
process_interrupted = _is_signal_interrupted_exit(event.exit_code)
|
||||
process_failed = event.exit_code not in (0, PROCESS_EXIT_SKIPPED) and not process_interrupted
|
||||
process_failed = _status_for_process_without_archive_result(event) == "failed"
|
||||
with _perf_span("archivebox.ArchiveResultService.on_ProcessCompletedEvent.emit_archive_result_fallback"):
|
||||
await event.emit(
|
||||
ArchiveResultEvent(
|
||||
snapshot_id=snapshot_event.snapshot_id,
|
||||
plugin=event.plugin_name,
|
||||
hook_name=event.hook_name,
|
||||
status=(
|
||||
"queued"
|
||||
if process_interrupted
|
||||
else "failed"
|
||||
if process_failed
|
||||
else ("succeeded" if _has_content_files(event.output_files) else "noresult")
|
||||
),
|
||||
status=_status_for_process_without_archive_result(event),
|
||||
output_str=event.stderr if process_failed else "",
|
||||
output_files=event.output_files,
|
||||
start_ts=event.start_ts,
|
||||
|
||||
+208
-57
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
@@ -181,6 +182,9 @@ def ensure_background_runner(*, allow_under_pytest: bool = False) -> bool:
|
||||
runner_worker = get_worker(supervisor, "worker_runner") if supervisor else None
|
||||
if runner_worker and runner_worker.get("statename") in ("STARTING", "RUNNING"):
|
||||
return False
|
||||
if supervisor is not None:
|
||||
start_worker(supervisor, RUNNER_WORKER)
|
||||
return True
|
||||
|
||||
machine = Machine.current()
|
||||
Process.cleanup_stale_running(machine=machine)
|
||||
@@ -192,13 +196,6 @@ def ensure_background_runner(*, allow_under_pytest: bool = False) -> bool:
|
||||
if any(proc.is_running for proc in running_orchestrators):
|
||||
return False
|
||||
|
||||
if supervisor is not None:
|
||||
try:
|
||||
start_worker(supervisor, RUNNER_WORKER)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -569,7 +566,7 @@ class CrawlRunner:
|
||||
current_process.machine = current_iface.machine
|
||||
current_process.save(update_fields=["iface", "machine", "modified_at"])
|
||||
self.persona = self.crawl.resolve_persona()
|
||||
self.base_config = get_config(crawl=self.crawl)
|
||||
self.base_config = get_config(crawl=self.crawl, overrides=self.config_overrides)
|
||||
self.derived_config = dict(Machine.current().config or {})
|
||||
self.crawl_output_dir = str(self.crawl.output_dir)
|
||||
if self.persona:
|
||||
@@ -579,7 +576,6 @@ class CrawlRunner:
|
||||
chrome_binary=self.base_config["CHROME_BINARY"],
|
||||
),
|
||||
)
|
||||
self.base_config.update(self.config_overrides)
|
||||
if self.selected_plugins is None:
|
||||
raw_plugins = str(self.base_config.get("PLUGINS") or "").strip()
|
||||
if raw_plugins:
|
||||
@@ -606,10 +602,74 @@ class CrawlRunner:
|
||||
return [str(snapshot.id) for snapshot in pending_snapshots]
|
||||
if self.crawl.snapshot_set.exclude(status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED]).exists():
|
||||
return []
|
||||
created = self.crawl.create_snapshots_from_urls()
|
||||
snapshots = created or list(self.crawl.snapshot_set.filter(depth=0).order_by("created_at"))
|
||||
created = self.create_initial_snapshots()
|
||||
snapshots = created or list(self.crawl.snapshot_set.filter(depth__in=[0, 1]).order_by("depth", "created_at"))
|
||||
return [str(snapshot.id) for snapshot in snapshots]
|
||||
|
||||
def create_initial_snapshots(self) -> list:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
if self.crawl.snapshot_set.exists():
|
||||
return []
|
||||
|
||||
# Direct URL crawls (CLI add, REST, crawl create, schedule, ORM) seed
|
||||
# Crawl.urls with either explicit CrawlSeed JSONL or one plain URL per
|
||||
# line. Either shape becomes the input-layer (depth=0) snapshots so the
|
||||
# rest of the system has one parsing convention. Anything else (RSS,
|
||||
# Netscape HTML, JSONL imports, free-form text) falls through to the
|
||||
# synthetic root path below for parser hooks to process.
|
||||
records = []
|
||||
for line in (self.crawl.urls or "").splitlines():
|
||||
raw_line = line.strip()
|
||||
if not raw_line or raw_line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
record = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
if raw_line.startswith(("http://", "https://")):
|
||||
records.append({"type": "CrawlSeed", "url": raw_line, "depth": 0})
|
||||
continue
|
||||
records = []
|
||||
break
|
||||
if not isinstance(record, dict) or record.get("type") != "CrawlSeed" or not record.get("url"):
|
||||
records = []
|
||||
break
|
||||
records.append(record)
|
||||
|
||||
if records:
|
||||
created = []
|
||||
for record in records:
|
||||
try:
|
||||
record["depth"] = int(record.get("depth") or 0)
|
||||
except (TypeError, ValueError):
|
||||
record["depth"] = 0
|
||||
for depth in sorted({record["depth"] for record in records}):
|
||||
depth_records = [record for record in records if record["depth"] == depth]
|
||||
created.extend(self.crawl.create_discovered_snapshots(None, depth_records, depth=depth))
|
||||
return created
|
||||
|
||||
# Raw stdin/API/UI import text must remain verbatim in Crawl.urls so a
|
||||
# resumed crawl sees the exact same source bytes and cannot reparse an
|
||||
# overwritten temp file. Parser hooks still need the normal Snapshot
|
||||
# lifecycle and SNAP_DIR/staticfile convention, so the runner creates a
|
||||
# single synthetic root only after it has claimed the crawl. This keeps
|
||||
# DB/FS side effects out of request/CLI add paths and lets child URLs be
|
||||
# discovered by the usual parser -> CrawlService -> Snapshot flow.
|
||||
root_snapshot = Snapshot(
|
||||
url=Snapshot.INTERNAL_INPUT_URL,
|
||||
crawl=self.crawl,
|
||||
depth=0,
|
||||
title="stdin.txt",
|
||||
status=Snapshot.StatusChoices.QUEUED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
root_snapshot.set_delete_at_from_config(self.base_config.get("DELETE_AFTER", "0"))
|
||||
root_snapshot.save()
|
||||
staticfile_dir = root_snapshot.output_dir / "staticfile"
|
||||
staticfile_dir.mkdir(parents=True, exist_ok=True)
|
||||
(staticfile_dir / "stdin.txt").write_text(self.crawl.urls, encoding="utf-8")
|
||||
return [root_snapshot]
|
||||
|
||||
def finalize_run_state(self) -> None:
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.models import Snapshot
|
||||
@@ -704,8 +764,7 @@ class CrawlRunner:
|
||||
snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").get(id=snapshot_id)
|
||||
self.crawl = snapshot.crawl
|
||||
self.persona = snapshot.crawl.resolve_persona()
|
||||
self.base_config = get_config(crawl=snapshot.crawl, persona=self.persona)
|
||||
self.base_config.update(self.config_overrides)
|
||||
self.base_config = get_config(crawl=snapshot.crawl, persona=self.persona, overrides=self.config_overrides)
|
||||
self.crawl_output_dir = str(snapshot.crawl.output_dir)
|
||||
runtime_chrome_overrides = {}
|
||||
if self.persona:
|
||||
@@ -721,8 +780,8 @@ class CrawlRunner:
|
||||
crawl_downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
runtime_chrome_overrides.update(
|
||||
{
|
||||
"CHROME_USER_DATA_DIR": str(self.persona.runtime_profile_dir_for_crawl(snapshot.crawl)),
|
||||
"CHROME_DOWNLOADS_DIR": str(crawl_downloads_dir),
|
||||
"PERSONAS_DIR": str(self.persona.runtime_root_for_crawl(snapshot.crawl).parent),
|
||||
"ACTIVE_PERSONA": self.persona.name,
|
||||
},
|
||||
)
|
||||
snapshot_output_dir = str(snapshot.output_dir)
|
||||
@@ -771,6 +830,17 @@ class CrawlRunner:
|
||||
if not discovered_urls:
|
||||
return
|
||||
|
||||
if self.crawl.status == self.crawl.StatusChoices.SEALED:
|
||||
# Snapshot completion projectors can observe the root snapshot seal
|
||||
# before the runner has consumed parser urls.jsonl output. A sealed
|
||||
# crawl must not block those freshly discovered child snapshots; the
|
||||
# runner is still inside the same crawl lifecycle and will seal it
|
||||
# again after the discovered queue is empty.
|
||||
await sync_to_async(self.crawl.update_and_requeue, thread_sensitive=True)(
|
||||
status=self.crawl.StatusChoices.STARTED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
|
||||
parent_snapshot = await sync_to_async(
|
||||
lambda: Snapshot.objects.select_related("crawl", "crawl__created_by").filter(id=snapshot_payload["id"]).first(),
|
||||
thread_sensitive=True,
|
||||
@@ -1027,6 +1097,15 @@ class CrawlRunner:
|
||||
snapshot_selected_plugins = (
|
||||
self.selected_plugins if self.selected_plugins_from_args else (snapshot_config_plugins or self.selected_plugins)
|
||||
)
|
||||
|
||||
def queued_plugins_selected_by_config(queued_plugins: list[str]) -> list[str]:
|
||||
if not snapshot_selected_plugins:
|
||||
return queued_plugins
|
||||
expanded_selected_plugins = set(
|
||||
filter_plugins(self.plugins, snapshot_selected_plugins, include_providers=True).keys(),
|
||||
)
|
||||
return [plugin for plugin in queued_plugins if plugin in expanded_selected_plugins]
|
||||
|
||||
selected_hooks_by_plugin = None
|
||||
if snapshot["status"] == "started":
|
||||
_reset_count, running_count = await sync_to_async(snapshot["_snapshot"].reset_abandoned_results, thread_sensitive=True)()
|
||||
@@ -1051,7 +1130,7 @@ class CrawlRunner:
|
||||
)(snapshot["id"])
|
||||
if queued_plugins:
|
||||
if snapshot_selected_plugins:
|
||||
queued_plugins = [plugin for plugin in queued_plugins if plugin in snapshot_selected_plugins]
|
||||
queued_plugins = queued_plugins_selected_by_config(queued_plugins)
|
||||
selected_hooks_by_plugin = {
|
||||
plugin: hooks for plugin, hooks in (selected_hooks_by_plugin or {}).items() if plugin in queued_plugins
|
||||
}
|
||||
@@ -1063,7 +1142,7 @@ class CrawlRunner:
|
||||
)(snapshot["id"])
|
||||
if queued_plugins:
|
||||
if snapshot_selected_plugins:
|
||||
queued_plugins = [plugin for plugin in queued_plugins if plugin in snapshot_selected_plugins]
|
||||
queued_plugins = queued_plugins_selected_by_config(queued_plugins)
|
||||
selected_hooks_by_plugin = {
|
||||
plugin: hooks for plugin, hooks in (selected_hooks_by_plugin or {}).items() if plugin in queued_plugins
|
||||
}
|
||||
@@ -1092,7 +1171,7 @@ class CrawlRunner:
|
||||
thread_sensitive=True,
|
||||
)(snapshot["id"])
|
||||
if snapshot_selected_plugins and remaining_queued_plugins:
|
||||
remaining_queued_plugins = [plugin for plugin in remaining_queued_plugins if plugin in snapshot_selected_plugins]
|
||||
remaining_queued_plugins = queued_plugins_selected_by_config(remaining_queued_plugins)
|
||||
if not remaining_queued_plugins:
|
||||
await sync_to_async(run_snapshot_maintenance, thread_sensitive=True)(snapshot_id, output_dir=output_dir)
|
||||
return
|
||||
@@ -1157,17 +1236,36 @@ class CrawlRunner:
|
||||
await sync_to_async(run_snapshot_maintenance, thread_sensitive=True)(snapshot_id, output_dir=output_dir)
|
||||
return
|
||||
await self.enqueue_discovered_snapshots_from_outputs(snapshot)
|
||||
await sync_to_async(
|
||||
lambda: (
|
||||
self.crawl.sm.seal()
|
||||
if self.crawl.status == self.crawl.StatusChoices.STARTED
|
||||
and not self.crawl.snapshot_set.filter(
|
||||
status__in=self.crawl.snapshot_set.model.OPEN_STATES,
|
||||
).exists()
|
||||
else None
|
||||
),
|
||||
thread_sensitive=True,
|
||||
)()
|
||||
|
||||
def _seal_when_last_snapshot_finished() -> None:
|
||||
# run_snapshot replaces self.crawl per-snapshot, so multiple
|
||||
# concurrent tasks each load a fresh Crawl/SM pointing at the
|
||||
# same DB row. The "no open snapshots" check is non-atomic
|
||||
# with sm.seal(), so two tasks racing to finish the last
|
||||
# snapshot can both pass the guard. The first call drives
|
||||
# the SM to a final state (engine.running=False); the loser
|
||||
# then raises TransitionNotAllowed even though current_state
|
||||
# still reads STARTED off its stale model field. Re-read the
|
||||
# row right before the call and swallow the race so the
|
||||
# task that lost the lap doesn't fail the whole snapshot.
|
||||
from statemachine.exceptions import TransitionNotAllowed
|
||||
|
||||
crawl = self.crawl
|
||||
crawl.refresh_from_db(fields=["status"])
|
||||
if crawl.status != crawl.StatusChoices.STARTED:
|
||||
return
|
||||
if crawl.snapshot_set.filter(
|
||||
status__in=crawl.snapshot_set.model.OPEN_STATES,
|
||||
).exists():
|
||||
return
|
||||
try:
|
||||
crawl.sm.seal()
|
||||
except TransitionNotAllowed:
|
||||
# Another task sealed it between our refresh and the
|
||||
# SM call. Idempotent by design.
|
||||
pass
|
||||
|
||||
await sync_to_async(_seal_when_last_snapshot_finished, thread_sensitive=True)()
|
||||
finally:
|
||||
snapshot_service.close()
|
||||
|
||||
@@ -1177,9 +1275,11 @@ class CrawlRunner:
|
||||
snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").filter(id=snapshot_id).first()
|
||||
if snapshot is None or snapshot.status == Snapshot.StatusChoices.SEALED:
|
||||
return
|
||||
if snapshot.status == Snapshot.StatusChoices.STARTED:
|
||||
snapshot.sm.seal()
|
||||
return
|
||||
# Limit stops are runner-owned cancellation decisions, not normal
|
||||
# "all ArchiveResults finished" lifecycle seals. Updating the row
|
||||
# directly avoids racing the state machine's in-memory state while
|
||||
# concurrent snapshot tasks are stopping because the crawl-wide limit
|
||||
# has already been reached.
|
||||
snapshot.update_and_requeue(
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
retry_at=None,
|
||||
@@ -1334,6 +1434,15 @@ def queued_plugins_for_snapshot(snapshot_id: str) -> list[str] | None:
|
||||
return queued_plugins
|
||||
|
||||
|
||||
def config_overrides_for_queued_plugins(selected_plugins: list[str], **overrides: Any) -> dict[str, Any]:
|
||||
config_overrides = dict(overrides)
|
||||
config_overrides["PLUGINS"] = ",".join(selected_plugins)
|
||||
for plugin_name in selected_plugins:
|
||||
if plugin_name.startswith("search_backend_"):
|
||||
config_overrides[f"{plugin_name.upper()}_ENABLED"] = True
|
||||
return config_overrides
|
||||
|
||||
|
||||
def fail_unavailable_queued_hooks(
|
||||
snapshot_id: str,
|
||||
selected_hooks_by_plugin: dict[str, set[str] | None],
|
||||
@@ -1343,11 +1452,14 @@ def fail_unavailable_queued_hooks(
|
||||
|
||||
now = timezone.now()
|
||||
for plugin_name, selected_hook_names in selected_hooks_by_plugin.items():
|
||||
if selected_hook_names is None or plugin_name not in plugins:
|
||||
if selected_hook_names is None:
|
||||
continue
|
||||
available_hook_names = {
|
||||
name for hook in plugins[plugin_name].filter_hooks("Snapshot") for name in (hook.name, Path(hook.name).stem)
|
||||
}
|
||||
if plugin_name in plugins:
|
||||
available_hook_names = {
|
||||
name for hook in plugins[plugin_name].filter_hooks("Snapshot") for name in (hook.name, Path(hook.name).stem)
|
||||
}
|
||||
else:
|
||||
available_hook_names = set()
|
||||
missing_hook_names = [hook_name for hook_name in selected_hook_names if hook_name not in available_hook_names]
|
||||
if not missing_hook_names:
|
||||
continue
|
||||
@@ -1398,6 +1510,7 @@ def include_background_prerequisite_hooks(
|
||||
|
||||
def snapshot_hooks_for_pending_archiveresults(snapshot) -> list[tuple[str, str]]:
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
|
||||
snapshot_plugin_names = [name.strip() for name in str((snapshot.config or {}).get("PLUGINS") or "").split(",") if name.strip()]
|
||||
@@ -1409,6 +1522,8 @@ def snapshot_hooks_for_pending_archiveresults(snapshot) -> list[tuple[str, str]]
|
||||
if plugin_names
|
||||
else _discover_archivebox_plugins()
|
||||
)
|
||||
if snapshot.url == Snapshot.INTERNAL_INPUT_URL:
|
||||
plugins = {name: plugin for name, plugin in plugins.items() if getattr(plugin.config, "x_accepts_internal_input", False)}
|
||||
return sorted((plugin.name, hook.name) for plugin in plugins.values() for hook in plugin.filter_hooks("Snapshot"))
|
||||
|
||||
|
||||
@@ -1579,22 +1694,36 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo
|
||||
_runner_console_line(crawl_id=snapshot.crawl_id, snapshot=snapshot)
|
||||
# Explicit maintenance, e.g. `archivebox update --index-only`, may
|
||||
# need to run search/index hooks for a paused snapshot. That should
|
||||
# not resume the crawl or make unrelated queued work runnable, so
|
||||
# selected_plugins is required and the paused state is restored in
|
||||
# the finally block below.
|
||||
# not resume the crawl or make unrelated queued work runnable. The
|
||||
# queued ArchiveResult rows are the durable maintenance request, so
|
||||
# run that exact plugin set even when the paused crawl's normal
|
||||
# PLUGINS config names a different extractor surface.
|
||||
run_crawl(
|
||||
str(snapshot.crawl_id),
|
||||
snapshot_ids=[str(snapshot.id)],
|
||||
selected_plugins=selected_plugins,
|
||||
process_discovered_snapshots_inline=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
config_overrides=config_overrides_for_queued_plugins(selected_plugins),
|
||||
selected_plugins_are_explicit=False,
|
||||
)
|
||||
finally:
|
||||
# Targeted plugin rows can complete while the Snapshot remains
|
||||
# paused. Put retry_at back at MAX so the orchestrator leaves the
|
||||
# paused lifecycle alone until an explicit resume transition.
|
||||
snapshot.restore_paused_scheduler_marker()
|
||||
# paused. Put retry_at back at MAX only after the queued rows are
|
||||
# gone; if a hook was interrupted before projection, keep the
|
||||
# paused row due so the next runner can retry that targeted work
|
||||
# without a user-visible resume transition.
|
||||
if queued_plugins_for_snapshot(str(snapshot.id)):
|
||||
now = timezone.now()
|
||||
type(snapshot).objects.filter(
|
||||
pk=snapshot.pk,
|
||||
status=snapshot.StatusChoices.PAUSED,
|
||||
).update(
|
||||
retry_at=now,
|
||||
modified_at=now,
|
||||
)
|
||||
else:
|
||||
snapshot.restore_paused_scheduler_marker()
|
||||
return True
|
||||
if snapshot.status == Snapshot.StatusChoices.SEALED:
|
||||
if not Snapshot.claim_for_worker(snapshot, lock_seconds=lock_seconds):
|
||||
@@ -1620,6 +1749,7 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo
|
||||
selected_plugins=selected_plugins,
|
||||
process_discovered_snapshots_inline=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
config_overrides=config_overrides_for_queued_plugins(selected_plugins),
|
||||
selected_plugins_are_explicit=False,
|
||||
)
|
||||
if search_only_plugins:
|
||||
@@ -1637,6 +1767,14 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo
|
||||
retry_at=None,
|
||||
modified_at=timezone.now(),
|
||||
)
|
||||
else:
|
||||
type(snapshot).objects.filter(
|
||||
pk=snapshot.pk,
|
||||
status=snapshot.StatusChoices.SEALED,
|
||||
).update(
|
||||
retry_at=timezone.now(),
|
||||
modified_at=timezone.now(),
|
||||
)
|
||||
return True
|
||||
if maintenance_ran:
|
||||
return True
|
||||
@@ -1678,7 +1816,14 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo
|
||||
run_crawl(
|
||||
str(snapshot.crawl_id),
|
||||
snapshot_ids=[str(snapshot.id)],
|
||||
selected_plugins=queued_plugins_for_snapshot(str(snapshot.id)),
|
||||
# Do not pass this snapshot's queued plugin set as a crawl-wide runner
|
||||
# filter. Internal import roots intentionally queue only parser hooks,
|
||||
# but any child snapshots discovered from that root must still run the
|
||||
# normal crawl plugin surface. run_snapshot() narrows the current
|
||||
# snapshot from its queued ArchiveResult rows immediately before
|
||||
# execution; leaving the runner unconstrained keeps that narrowing
|
||||
# local to the one snapshot that owns those rows.
|
||||
selected_plugins=None,
|
||||
process_discovered_snapshots_inline=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
selected_plugins_are_explicit=False,
|
||||
@@ -1709,6 +1854,7 @@ def run_due_binary(binary, *, lock_seconds: int) -> bool:
|
||||
async def _run_install(plugin_names: list[str] | None = None) -> None:
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.machine.models import Machine
|
||||
from archivebox.plugins.discovery import get_enabled_plugins
|
||||
|
||||
plugins = _discover_archivebox_plugins()
|
||||
config = get_config(include_machine=False)
|
||||
@@ -1729,10 +1875,13 @@ async def _run_install(plugin_names: list[str] | None = None) -> None:
|
||||
bus_destroyed = False
|
||||
|
||||
try:
|
||||
selected_plugins = filter_plugins(plugins, list(plugin_names), include_providers=True) if plugin_names else plugins
|
||||
if plugin_names:
|
||||
selected_plugins = filter_plugins(plugins, list(plugin_names), include_providers=True)
|
||||
else:
|
||||
selected_plugins = filter_plugins(plugins, get_enabled_plugins(config=config), include_providers=True)
|
||||
if not selected_plugins:
|
||||
return
|
||||
plugins_label = ", ".join(plugin_names) if plugin_names else f"all ({len(plugins)} available)"
|
||||
plugins_label = ", ".join(plugin_names) if plugin_names else f"enabled ({len(selected_plugins)} of {len(plugins)} available)"
|
||||
timeout_seconds = config["TIMEOUT"]
|
||||
stdout_is_tty = sys.stdout.isatty()
|
||||
stderr_is_tty = sys.stderr.isatty()
|
||||
@@ -1803,7 +1952,7 @@ async def _run_install(plugin_names: list[str] | None = None) -> None:
|
||||
await bus.destroy(clear=False)
|
||||
bus_destroyed = True
|
||||
if live_ui is not None:
|
||||
live_ui.print_summary(output_dir=output_dir)
|
||||
live_ui.print_summary(output_dir=output_dir, archive_results=[])
|
||||
finally:
|
||||
if not bus_destroyed:
|
||||
await bus.destroy(clear=False)
|
||||
@@ -1898,7 +2047,7 @@ def _run_due_queued_plugin_result(
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
plugin__in=plugin_names,
|
||||
snapshot__retry_at__lte=now,
|
||||
snapshot__status=Snapshot.StatusChoices.SEALED,
|
||||
snapshot__status__in=(Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED),
|
||||
)
|
||||
.filter(**({"snapshot__crawl_id": crawl_id} if crawl_id else {}))
|
||||
.values("snapshot_id", "snapshot__crawl_id")[:1]
|
||||
@@ -1908,6 +2057,15 @@ def _run_due_queued_plugin_result(
|
||||
return False
|
||||
root_crawl_id = str(first_due_results[0]["snapshot__crawl_id"])
|
||||
|
||||
first_due_snapshot = Snapshot.objects.filter(pk=first_due_results[0]["snapshot_id"]).first()
|
||||
if first_due_snapshot and first_due_snapshot.status == Snapshot.StatusChoices.PAUSED:
|
||||
return run_due_snapshot(
|
||||
first_due_snapshot,
|
||||
lock_seconds=lock_seconds,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
|
||||
due_snapshots = Snapshot.objects.filter(
|
||||
retry_at__lte=now,
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
@@ -1955,20 +2113,13 @@ def _run_due_queued_plugin_result(
|
||||
if not claimed_snapshot_ids or selected_plugins is None:
|
||||
return True
|
||||
|
||||
config_overrides = {
|
||||
"CRAWL_MAX_CONCURRENT_SNAPSHOTS": batch_size,
|
||||
}
|
||||
for plugin_name in selected_plugins:
|
||||
if plugin_name.startswith("search_backend_"):
|
||||
config_overrides[f"{plugin_name.upper()}_ENABLED"] = True
|
||||
|
||||
run_crawl(
|
||||
root_crawl_id,
|
||||
snapshot_ids=claimed_snapshot_ids,
|
||||
selected_plugins=selected_plugins,
|
||||
process_discovered_snapshots_inline=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
config_overrides=config_overrides,
|
||||
config_overrides=config_overrides_for_queued_plugins(selected_plugins, CRAWL_MAX_CONCURRENT_SNAPSHOTS=batch_size),
|
||||
selected_plugins_are_explicit=False,
|
||||
)
|
||||
if all(plugin.startswith("search_backend_") for plugin in selected_plugins):
|
||||
@@ -2090,7 +2241,7 @@ def run_pending_crawls(
|
||||
while True:
|
||||
raise_if_shutdown_requested()
|
||||
now_monotonic = time.monotonic()
|
||||
if now_monotonic - last_retention_at >= (60.0 if daemon else 1.0):
|
||||
if crawl_id is None and now_monotonic - last_retention_at >= (60.0 if daemon else 1.0):
|
||||
for model in (ArchiveResult, Snapshot, Crawl, Process):
|
||||
# Keep the tight scheduler loop anchored on indexed delete_at
|
||||
# columns only. Backfilling missing delete_at values has to read
|
||||
@@ -2272,7 +2423,7 @@ def run_pending_crawls(
|
||||
continue
|
||||
|
||||
now_monotonic = time.monotonic()
|
||||
if now_monotonic - last_retention_repair_at >= (60.0 if daemon else 0.0):
|
||||
if crawl_id is None and now_monotonic - last_retention_repair_at >= (60.0 if daemon else 0.0):
|
||||
for model in (ArchiveResult, Snapshot, Crawl, Process):
|
||||
# No runnable work was found on this scheduler pass. This is
|
||||
# the bounded repair point for missing retention deadlines,
|
||||
|
||||
@@ -229,13 +229,14 @@
|
||||
<code class="card-url" title="{{obj.url}}">{{obj.url}}</code>
|
||||
<div class="card-media">
|
||||
<a href="{% snapshot_base_url obj %}/index.html" class="card-thumbnail">
|
||||
{% snapshot_url obj 'screenshot/screenshot.png' as screenshot_plugin_url %}
|
||||
{% snapshot_url obj 'screenshot.png' as screenshot_root_url %}
|
||||
{% snapshot_url obj 'favicon.ico' as favicon_root_url %}
|
||||
{% snapshot_archiveresult_url obj 'screenshot' 'screenshot.png' as screenshot_url %}
|
||||
{% snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot-1.png' as extension_screenshot_1_url %}
|
||||
{% snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot.png' as extension_screenshot_url %}
|
||||
{% snapshot_archiveresult_url obj 'favicon' 'favicon.ico' as favicon_url %}
|
||||
<img
|
||||
src="{{ screenshot_plugin_url }}"
|
||||
src="{% firstof screenshot_url extension_screenshot_1_url extension_screenshot_url favicon_url '/static/spinner.gif' %}"
|
||||
alt="{{obj.title|default:'Not yet archived...'}}"
|
||||
data-fallbacks="{{ screenshot_root_url }},{{ favicon_root_url }}"
|
||||
data-fallbacks="{{ extension_screenshot_1_url }},{{ extension_screenshot_url }},{{ favicon_url }}"
|
||||
onerror="const fallbacks=(this.dataset.fallbacks || '').split(',').filter(Boolean); if (fallbacks.length) { this.dataset.fallbacks=fallbacks.slice(1).join(','); this.src=fallbacks[0]; } else { this.onerror=null; this.closest('.card-thumbnail').classList.add('missing'); this.src='/static/spinner.gif'; }"
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -656,18 +656,22 @@
|
||||
return url
|
||||
}
|
||||
|
||||
function replaceRegionFromDocument(doc, selector) {
|
||||
const next = doc.querySelector(selector)
|
||||
const current = document.querySelector(selector)
|
||||
if (next && current) current.innerHTML = next.innerHTML
|
||||
}
|
||||
|
||||
async function renderNow() {
|
||||
const url = buildListUrl()
|
||||
const response = await fetch(url, {headers: {'X-Requested-With': 'XMLHttpRequest'}, signal: controller.signal})
|
||||
const html = await response.text()
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const nextList = doc.querySelector('.public-snapshot-list')
|
||||
const currentList = document.querySelector('.public-snapshot-list')
|
||||
if (nextList && currentList) {
|
||||
currentList.replaceWith(nextList)
|
||||
initPublicSearchMode()
|
||||
setSearchLoading(controller, resultVersion ? 'Searching... ' + resultVersion + ' found' : 'Searching...')
|
||||
}
|
||||
replaceRegionFromDocument(doc, '.public-snapshot-count')
|
||||
replaceRegionFromDocument(doc, '#table-bookmarks thead th:nth-child(3)')
|
||||
replaceRegionFromDocument(doc, '#table-bookmarks tbody')
|
||||
replaceRegionFromDocument(doc, '.public-pagination')
|
||||
setSearchLoading(controller, resultVersion ? 'Searching... ' + resultVersion + ' found' : 'Searching...')
|
||||
}
|
||||
|
||||
const listUrl = buildListUrl()
|
||||
|
||||
@@ -1150,8 +1150,13 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-title-line header-toggle-trigger">
|
||||
<img src="{% snapshot_url snapshot 'favicon/favicon.ico' %}" onerror="this.onerror=null;this.src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'><circle cx='10' cy='10' r='7.25'/><ellipse cx='10' cy='10' rx='3.25' ry='7.25'/><line x1='2.75' y1='10' x2='17.25' y2='10'/></svg>"" alt="Favicon" class="favicon"/>
|
||||
<span class="header-title-text">{{title|truncatechars:120|safe}}</span>
|
||||
{% snapshot_archiveresult_url snapshot 'favicon' 'favicon.ico' as header_favicon_url %}
|
||||
{% if header_favicon_url %}
|
||||
<img src="{{ header_favicon_url }}" onerror="this.onerror=null;this.src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'><circle cx='10' cy='10' r='7.25'/><ellipse cx='10' cy='10' rx='3.25' ry='7.25'/><line x1='2.75' y1='10' x2='17.25' y2='10'/></svg>"" alt="Favicon" class="favicon"/>
|
||||
{% else %}
|
||||
<img src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'><circle cx='10' cy='10' r='7.25'/><ellipse cx='10' cy='10' rx='3.25' ry='7.25'/><line x1='2.75' y1='10' x2='17.25' y2='10'/></svg>" alt="Favicon" class="favicon"/>
|
||||
{% endif %}
|
||||
<span class="header-title-text">{{title|truncatechars:120}}</span>
|
||||
{% if title_tags %}
|
||||
<span class="header-tags">
|
||||
<span class="tag-pill permission-pill">{{ snapshot_permissions_icon }} {{ snapshot_permissions }}</span>
|
||||
|
||||
@@ -65,7 +65,7 @@ def _assert_safe_runtime_paths(*, cwd: Path | None = None, env: dict[str, str] |
|
||||
|
||||
|
||||
def _test_source_pythonpath() -> str:
|
||||
entries: list[str] = []
|
||||
entries: list[str] = [str(REPO_ROOT.resolve(strict=False))]
|
||||
for repo_name in ("abxpkg", "abx-plugins", "abx-dl"):
|
||||
for repo_path in (WORKSPACE_ROOT / repo_name, REPO_ROOT / repo_name):
|
||||
if repo_path.exists():
|
||||
@@ -378,19 +378,18 @@ def isolated_data_dir(tmp_path):
|
||||
@pytest.fixture
|
||||
def hermetic_lib_dir(tmp_path, monkeypatch):
|
||||
"""
|
||||
Point LIB_DIR at a tmp directory so the test can write fake binaries
|
||||
Point ABXPKG_LIB_DIR at a tmp directory so the test can write fake binaries
|
||||
without touching the real ``~/Library/Application Support/abx/lib`` (which
|
||||
can contain symlinks to SIP-protected system binaries on macOS).
|
||||
|
||||
Opt-in only: most tests should reuse the cached real LIB_DIR for speed —
|
||||
Opt-in only: most tests should reuse the cached real ABXPKG_LIB_DIR for speed —
|
||||
rebuilding from scratch per-test adds ~10× overhead. Use this only when
|
||||
the test synthesizes binary paths or validates LIB_DIR-relative behavior.
|
||||
the test synthesizes binary paths or validates ABXPKG_LIB_DIR-relative behavior.
|
||||
"""
|
||||
import archivebox.machine.models as machine_models
|
||||
|
||||
lib_dir = tmp_path / "lib"
|
||||
(lib_dir / "bin").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("LIB_DIR", str(lib_dir))
|
||||
monkeypatch.setenv("ABXPKG_LIB_DIR", str(lib_dir))
|
||||
machine_models._CURRENT_MACHINE = None
|
||||
machine_models._CURRENT_PROCESS = None
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import pytest
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
|
||||
from .conftest import (
|
||||
api_client_request,
|
||||
@@ -12,7 +15,7 @@ from .conftest import (
|
||||
start_archivebox_server,
|
||||
stop_server,
|
||||
)
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.core.models import Snapshot, SnapshotTag
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.tests.test_orm_helpers import use_archivebox_db
|
||||
|
||||
@@ -159,25 +162,43 @@ def wait_for_import_processing(cwd: Path, expected_urls: set[str], *, timeout: f
|
||||
raise AssertionError("timed out waiting for import crawl processing to start")
|
||||
|
||||
|
||||
def wait_for_expected_import_snapshots(cwd: Path, expected_urls: set[str], *, timeout: float = 180.0) -> None:
|
||||
def wait_for_expected_import_snapshots(
|
||||
cwd: Path,
|
||||
expected_urls: set[str],
|
||||
*,
|
||||
timeout: float = 180.0,
|
||||
expected_tags: set[str] | None = None,
|
||||
) -> None:
|
||||
import time
|
||||
|
||||
allowed_statuses = {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
with use_archivebox_db(cwd):
|
||||
rows = list(Snapshot.objects.filter(url__in=expected_urls).values_list("url", "status"))
|
||||
snapshots = list(Snapshot.objects.filter(url__in=expected_urls).values("id", "url", "status"))
|
||||
tag_names_by_snapshot_id = {}
|
||||
if expected_tags and snapshots:
|
||||
for snapshot_id, tag_name in SnapshotTag.objects.filter(
|
||||
snapshot_id__in=[snapshot["id"] for snapshot in snapshots],
|
||||
).values_list("snapshot_id", "tag__name"):
|
||||
tag_names_by_snapshot_id.setdefault(snapshot_id, set()).add(tag_name)
|
||||
counts = {url: 0 for url in expected_urls}
|
||||
bad_statuses = []
|
||||
for url, status in rows:
|
||||
counts[url] += 1
|
||||
if status not in allowed_statuses:
|
||||
bad_statuses.append((url, status))
|
||||
if all(count == 1 for count in counts.values()) and not bad_statuses:
|
||||
missing_tags = {}
|
||||
for snapshot in snapshots:
|
||||
counts[snapshot["url"]] += 1
|
||||
if snapshot["status"] not in allowed_statuses:
|
||||
bad_statuses.append((snapshot["url"], snapshot["status"]))
|
||||
if expected_tags:
|
||||
tag_names = tag_names_by_snapshot_id.get(snapshot["id"], set())
|
||||
missing = expected_tags - tag_names
|
||||
if missing:
|
||||
missing_tags[snapshot["url"]] = missing
|
||||
if all(count == 1 for count in counts.values()) and not bad_statuses and not missing_tags:
|
||||
return
|
||||
time.sleep(1)
|
||||
raise AssertionError(
|
||||
f"timed out waiting for one queued/started/sealed snapshot per URL, got counts={counts}, bad_statuses={bad_statuses}",
|
||||
f"timed out waiting for one queued/started/sealed snapshot per URL, got counts={counts}, bad_statuses={bad_statuses}, missing_tags={missing_tags}",
|
||||
)
|
||||
|
||||
|
||||
@@ -243,10 +264,68 @@ def test_basic_success_case_request(client, tmp_path, api_headers):
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json()["success"] is True
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
assert crawl.urls == submitted_url
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") == submitted_url
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": submitted_url, "depth": 0}
|
||||
assert Snapshot.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_api_cli_add_concurrent_first_time_default_persona_creation(tmp_path):
|
||||
"""Concurrent live API add requests should share one first-created Default persona."""
|
||||
init_archive(tmp_path)
|
||||
with use_archivebox_db(tmp_path):
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
Persona.objects.filter(name="Default").delete()
|
||||
assert Persona.objects.filter(name="Default").count() == 0
|
||||
|
||||
port = get_free_port()
|
||||
env = cli_env(port=port, server=True, USE_COLOR="False", SHOW_PROGRESS="False")
|
||||
api_token = create_admin_and_token(tmp_path)
|
||||
submitted_urls = [f"https://example.com/api-cli-add-concurrent-persona-{idx}" for idx in range(4)]
|
||||
start = Event()
|
||||
|
||||
def post_add(url: str):
|
||||
start.wait(timeout=10)
|
||||
return live_api_request(
|
||||
port,
|
||||
"post",
|
||||
"/api/v1/cli/add",
|
||||
api_token=api_token,
|
||||
timeout=60,
|
||||
json={
|
||||
"urls": [url],
|
||||
"depth": 0,
|
||||
"parser": "url_list",
|
||||
"plugins": "__archivebox_test_no_plugins__",
|
||||
"index_only": True,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
start_archivebox_server(tmp_path, env=env, port=port)
|
||||
with ThreadPoolExecutor(max_workers=len(submitted_urls)) as pool:
|
||||
futures = [pool.submit(post_add, url) for url in submitted_urls]
|
||||
start.set()
|
||||
responses = [future.result(timeout=75) for future in futures]
|
||||
finally:
|
||||
stop_server(tmp_path)
|
||||
|
||||
assert [response.status_code for response in responses] == [200] * len(responses), [response.text[:500] for response in responses]
|
||||
bodies = [response.json() for response in responses]
|
||||
assert all(body["success"] is True for body in bodies)
|
||||
assert {body["result"]["queued_urls"][0] for body in bodies} == set(submitted_urls)
|
||||
|
||||
with use_archivebox_db(tmp_path):
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
assert Persona.objects.filter(name="Default").count() == 1
|
||||
crawls = list(Crawl.objects.order_by("urls").values_list("urls", flat=True))
|
||||
assert Snapshot.objects.count() == 0
|
||||
|
||||
expected_crawl_sources = sorted(
|
||||
json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in submitted_urls
|
||||
)
|
||||
assert crawls == expected_crawl_sources
|
||||
|
||||
|
||||
@pytest.mark.timeout(360)
|
||||
@@ -281,13 +360,26 @@ def test_api_cli_add_import_text_formats_preserve_metadata_and_crawl_inner_urls(
|
||||
assert body["result"]["crawl_id"]
|
||||
with use_archivebox_db(tmp_path):
|
||||
crawl = Crawl.objects.get(id=body["result"]["crawl_id"])
|
||||
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
source_text = import_path.read_text(encoding="utf-8")
|
||||
assert crawl.urls == source_text
|
||||
assert root_input == source_text
|
||||
|
||||
deadline = time.time() + 240
|
||||
root_counts = {}
|
||||
while time.time() < deadline:
|
||||
with use_archivebox_db(tmp_path):
|
||||
root_counts = {
|
||||
str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all()
|
||||
}
|
||||
if root_counts and all(count == 1 for count in root_counts.values()):
|
||||
break
|
||||
time.sleep(1)
|
||||
assert root_counts and all(count == 1 for count in root_counts.values()), root_counts
|
||||
wait_for_import_processing(tmp_path, expected_urls)
|
||||
with use_archivebox_db(tmp_path):
|
||||
for crawl in Crawl.objects.all():
|
||||
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
assert root_input == crawl.urls
|
||||
stop_server(tmp_path)
|
||||
start_archivebox_server(tmp_path, env=env, port=port)
|
||||
wait_for_expected_import_snapshots(tmp_path, expected_urls)
|
||||
@@ -364,10 +456,13 @@ def test_api_cli_add_rejects_file_path_and_shell_injection_payloads(tmp_path):
|
||||
finally:
|
||||
stop_server(tmp_path)
|
||||
|
||||
wait_for_expected_import_snapshots(tmp_path, {safe_url}, timeout=30, expected_tags={"api-security"})
|
||||
assert_no_file_or_shell_payload_snapshots(tmp_path, canary=canary)
|
||||
with use_archivebox_db(tmp_path):
|
||||
snapshot = Snapshot.objects.get(url=safe_url)
|
||||
crawl = Crawl.objects.get()
|
||||
assert crawl.status in {Crawl.StatusChoices.STARTED, Crawl.StatusChoices.SEALED}
|
||||
assert crawl.status in {Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.SEALED}
|
||||
assert snapshot.status in {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
|
||||
assert "api-security" in set(snapshot.tags.values_list("name", flat=True))
|
||||
with use_archivebox_db(tmp_path):
|
||||
tag_names = set(SnapshotTag.objects.filter(snapshot=snapshot).values_list("tag__name", flat=True))
|
||||
assert "api-security" in tag_names
|
||||
@@ -42,7 +42,7 @@ def test_cli_api_add_search_update_remove_over_server(tmp_path):
|
||||
"tag": "api-cli",
|
||||
"depth": 0,
|
||||
"parser": "url_list",
|
||||
"plugins": "wget",
|
||||
"plugins": "parse_txt_urls,wget",
|
||||
"update": True,
|
||||
"overwrite": False,
|
||||
"index_only": True,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from archivebox.core.models import Snapshot, Tag
|
||||
@@ -31,6 +33,12 @@ def test_tag_snapshots_export_returns_jsonl(client, api_token, tagged_data):
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"].startswith("application/x-ndjson")
|
||||
assert f"tag-{tag.slug}-snapshots.jsonl" in response["Content-Disposition"]
|
||||
body = response.content.decode()
|
||||
assert '"type": "Snapshot"' in body
|
||||
assert '"tags": "Alpha Research"' in body
|
||||
rows = [json.loads(line) for line in response.content.decode().splitlines()]
|
||||
rows_by_url = {row["url"]: row for row in rows}
|
||||
assert set(rows_by_url) == {"https://example.com/one", "https://example.com/two"}
|
||||
assert rows_by_url["https://example.com/one"]["type"] == "Snapshot"
|
||||
assert rows_by_url["https://example.com/one"]["title"] == "Example One"
|
||||
assert rows_by_url["https://example.com/two"]["type"] == "Snapshot"
|
||||
assert rows_by_url["https://example.com/two"]["title"] == "Example Two"
|
||||
for row in rows:
|
||||
assert "Alpha Research" in row["tags"].split(",")
|
||||
@@ -3,7 +3,7 @@ import pytest
|
||||
|
||||
|
||||
from abxpkg.binary_service import BinaryRequestEvent
|
||||
from abx_dl.events import ArchiveResultEvent, ProcessEvent, ProcessStartedEvent
|
||||
from abx_dl.events import ArchiveResultEvent, ProcessCompletedEvent, ProcessEvent, ProcessStartedEvent, SnapshotEvent
|
||||
from abx_dl.orchestrator import create_bus
|
||||
from abx_dl.output_files import OutputFile
|
||||
|
||||
@@ -331,6 +331,55 @@ def test_process_completed_projects_noresults_archiveresult():
|
||||
assert result.output_str == "No title found"
|
||||
|
||||
|
||||
def test_process_completed_without_archive_result_does_not_infer_success_from_output_files(snapshot):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.services.archive_result_service import ArchiveResultService
|
||||
import asyncio
|
||||
|
||||
plugin_dir = Path(snapshot.output_dir) / "wget"
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "index.html").write_text("<html>downloaded but not reported</html>")
|
||||
|
||||
bus = create_bus(name="test_process_completed_without_archive_result_output_files")
|
||||
ArchiveResultService(bus)
|
||||
|
||||
snapshot_event = SnapshotEvent(
|
||||
url=snapshot.url,
|
||||
snapshot_id=str(snapshot.id),
|
||||
output_dir=str(snapshot.output_dir),
|
||||
)
|
||||
completed_event = ProcessCompletedEvent(
|
||||
plugin_name="wget",
|
||||
hook_name="on_Snapshot__06_wget.finite.bg",
|
||||
hook_path="/usr/bin/env",
|
||||
hook_args=[],
|
||||
env={},
|
||||
timeout=60,
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=0,
|
||||
status="succeeded",
|
||||
output_dir=str(plugin_dir),
|
||||
output_files=[OutputFile(path="index.html", extension="html", mimetype="text/html", size=36)],
|
||||
start_ts="2026-03-22T12:00:00+00:00",
|
||||
end_ts="2026-03-22T12:00:01+00:00",
|
||||
event_parent_id=snapshot_event.event_id,
|
||||
)
|
||||
|
||||
async def emit_events() -> None:
|
||||
await bus.emit(snapshot_event).now()
|
||||
await bus.emit(completed_event).now()
|
||||
await bus.wait_until_idle()
|
||||
|
||||
asyncio.run(emit_events())
|
||||
|
||||
result = ArchiveResult.objects.get(snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__06_wget.finite.bg")
|
||||
assert result.status == ArchiveResult.StatusChoices.NORESULTS
|
||||
assert result.output_str == ""
|
||||
assert result.output_files == {"index.html": {"extension": "html", "mimetype": "text/html", "size": 36}}
|
||||
_cleanup_machine_process_rows()
|
||||
|
||||
|
||||
def test_retry_failed_archiveresults_requeues_snapshot_in_queued_state():
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ def _runtime_env(data_dir: Path, bin_dir: Path, *, lib_dir: Path | None = None)
|
||||
assert archivebox_bin, "archivebox console script must be available for CLI tests"
|
||||
lib_dir = lib_dir or data_dir / "lib"
|
||||
return {
|
||||
"LIB_DIR": str(lib_dir),
|
||||
"ABXPKG_LIB_DIR": str(lib_dir),
|
||||
"PATH": os.pathsep.join([str(bin_dir), str(Path(archivebox_bin).parent), "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ Verify add creates snapshots in DB, crawls, source files, and archive directorie
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.machine.models import Process
|
||||
from archivebox.tests.conftest import (
|
||||
@@ -247,14 +247,11 @@ def test_add_single_url_records_url_in_crawl(initialized_archive):
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
snapshots = list(Snapshot.objects.all())
|
||||
|
||||
assert crawl.urls == "https://example.com"
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert crawl.get_urls_list() == ["https://example.com"]
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert root_snapshot.depth == 0
|
||||
assert root_input == "https://example.com"
|
||||
assert snapshots == []
|
||||
|
||||
|
||||
@pytest.mark.timeout(360)
|
||||
@@ -278,13 +275,27 @@ def test_add_stdin_import_formats_preserve_metadata_and_crawl_inner_urls(initial
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.order_by("-created_at").first()
|
||||
assert crawl is not None
|
||||
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
assert crawl.snapshot_set.count() == 0
|
||||
assert crawl.urls == source_text
|
||||
assert root_input == source_text
|
||||
|
||||
try:
|
||||
start_archivebox_server(initialized_archive, env=env, port=port)
|
||||
deadline = time.time() + 120
|
||||
root_counts = {}
|
||||
while time.time() < deadline:
|
||||
with use_archivebox_db(initialized_archive):
|
||||
root_counts = {
|
||||
str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all()
|
||||
}
|
||||
if root_counts and all(count == 1 for count in root_counts.values()):
|
||||
break
|
||||
time.sleep(1)
|
||||
assert root_counts and all(count == 1 for count in root_counts.values()), root_counts
|
||||
with use_archivebox_db(initialized_archive):
|
||||
for crawl in Crawl.objects.all():
|
||||
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
assert root_input == crawl.urls
|
||||
wait_for_import_processing(initialized_archive, expected_urls)
|
||||
stop_server(initialized_archive)
|
||||
start_archivebox_server(initialized_archive, env=env, port=port)
|
||||
@@ -362,8 +373,8 @@ def test_add_rejects_file_path_and_shell_injection_payloads(initialized_archive)
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_run_rejects_file_url_injected_directly_into_crawl_urls_with_sql(initialized_archive):
|
||||
"""Runner must validate Crawl.urls again when SQL bypasses normal add/create paths."""
|
||||
def test_run_rejects_file_url_injected_directly_into_crawl_urls_with_db_update(initialized_archive):
|
||||
"""Runner must validate Crawl.urls again when DB writes bypass normal add/create paths."""
|
||||
secret_url = "https://example.com/?archivebox-sql-crawl-file-secret=1"
|
||||
local_source = initialized_archive / "not_owned_by_crawl_urls.txt"
|
||||
local_source.write_text(f"{secret_url}\n", encoding="utf-8")
|
||||
@@ -378,12 +389,8 @@ def test_run_rejects_file_url_injected_directly_into_crawl_urls_with_sql(initial
|
||||
status=Crawl.StatusChoices.QUEUED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
bad_jsonl = json.dumps({"type": "Snapshot", "url": file_url, "depth": 0, "tags": "sql-file-url"})
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"UPDATE {Crawl._meta.db_table} SET urls = %s WHERE id = %s",
|
||||
[bad_jsonl, crawl.id.hex],
|
||||
)
|
||||
bad_jsonl = json.dumps({"type": "CrawlSeed", "url": file_url, "depth": 0, "tags": "sql-file-url"})
|
||||
Crawl.objects.filter(pk=crawl.pk).update(urls=bad_jsonl)
|
||||
|
||||
result = run_archivebox_cmd(
|
||||
["run", f"--crawl-id={crawl.id}"],
|
||||
@@ -403,7 +410,7 @@ def test_run_rejects_file_url_injected_directly_into_crawl_urls_with_sql(initial
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_run_rejects_depth_two_file_url_snapshot_injected_directly_with_sql(initialized_archive):
|
||||
def test_run_rejects_depth_two_file_url_snapshot_injected_directly_with_db_update(initialized_archive):
|
||||
"""A queued Snapshot row with a file:// URL must not be allowed to run hooks or recurse."""
|
||||
secret_url = "https://example.com/?archivebox-sql-depth2-file-secret=1"
|
||||
local_source = initialized_archive / "not_owned_by_depth_two_snapshot.txt"
|
||||
@@ -434,11 +441,7 @@ def test_run_rejects_depth_two_file_url_snapshot_injected_directly_with_sql(init
|
||||
status=Snapshot.StatusChoices.QUEUED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"UPDATE {Snapshot._meta.db_table} SET url = %s WHERE id = %s",
|
||||
[file_url, injected_snapshot.id.hex],
|
||||
)
|
||||
Snapshot.objects.filter(pk=injected_snapshot.pk).update(url=file_url)
|
||||
|
||||
result = run_archivebox_cmd(
|
||||
["run", f"--snapshot-id={injected_snapshot.id}"],
|
||||
@@ -462,8 +465,8 @@ def test_run_rejects_depth_two_file_url_snapshot_injected_directly_with_sql(init
|
||||
assert file_results == []
|
||||
|
||||
|
||||
def test_add_bg_queues_internal_input_root_snapshot(initialized_archive):
|
||||
"""Background add stores submitted input on an internal root snapshot for the runner."""
|
||||
def test_add_bg_queues_direct_url_snapshot(initialized_archive):
|
||||
"""Background add queues explicit URL arguments as real URL snapshots."""
|
||||
env = cli_env(disable_extractors=True)
|
||||
result = run_archivebox_cmd(
|
||||
["add", "--bg", "--depth=0", "https://example.com"],
|
||||
@@ -475,15 +478,126 @@ def test_add_bg_queues_internal_input_root_snapshot(initialized_archive):
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
snapshots = list(Snapshot.objects.all())
|
||||
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.retry_at is not None
|
||||
assert crawl.urls == "https://example.com"
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert root_snapshot.depth == 0
|
||||
assert root_input == "https://example.com"
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert snapshots == []
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_add_tagged_single_url_seals_without_duplicate_snapshot_tags(initialized_archive):
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"USE_COLOR": "False",
|
||||
"SHOW_PROGRESS": "False",
|
||||
"PLUGINS": "parse_txt_urls,title",
|
||||
"TIMEOUT": "60",
|
||||
"CRAWL_MAX_CONCURRENT_SNAPSHOTS": "1",
|
||||
},
|
||||
)
|
||||
result = run_archivebox_cmd(
|
||||
[
|
||||
"add",
|
||||
"--bg",
|
||||
"--depth=0",
|
||||
"--tag=tagged-single-url",
|
||||
"https://example.com/?archivebox-tagged-single-url=1",
|
||||
],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
run_queued_crawls(initialized_archive, env, timeout=180)
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
snapshots = list(Snapshot.objects.order_by("depth", "url"))
|
||||
tag_counts = {
|
||||
snapshot.url: SnapshotTag.objects.filter(snapshot=snapshot, tag__name="tagged-single-url").count() for snapshot in snapshots
|
||||
}
|
||||
results = list(
|
||||
ArchiveResult.objects.select_related("snapshot")
|
||||
.order_by("snapshot__depth", "snapshot__url", "plugin")
|
||||
.values_list("snapshot__url", "plugin", "status", "output_str"),
|
||||
)
|
||||
|
||||
assert crawl.status == Crawl.StatusChoices.SEALED
|
||||
assert [(snapshot.url, snapshot.depth, snapshot.status) for snapshot in snapshots] == [
|
||||
("https://example.com/?archivebox-tagged-single-url=1", 0, Snapshot.StatusChoices.SEALED),
|
||||
]
|
||||
assert tag_counts == {
|
||||
"https://example.com/?archivebox-tagged-single-url=1": 1,
|
||||
}
|
||||
by_url_plugin = {(url, plugin): status for url, plugin, status, _output in results}
|
||||
assert by_url_plugin[("https://example.com/?archivebox-tagged-single-url=1", "title")] == "succeeded"
|
||||
unexpected_failures = [(url, plugin, status, output) for url, plugin, status, output in results if status == "failed"]
|
||||
assert not unexpected_failures
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_add_title_hook_env_gets_canonical_runtime_config_without_archivebox_selectors_or_aliases(initialized_archive):
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"USE_COLOR": "False",
|
||||
"SHOW_PROGRESS": "False",
|
||||
"PLUGINS": "title",
|
||||
"SAVE_TITLE": "True",
|
||||
"SECRET_KEY": "hook-env-secret-must-not-leak",
|
||||
"PUBLIC_ADD_VIEW": "True",
|
||||
"ADMIN_PASSWORD": "hook-env-admin-password-must-not-leak",
|
||||
"URL_BLACKLIST": "$^",
|
||||
"ARCHIVEBOX_TEST_ARBITRARY_ENV": "preserved-user-env",
|
||||
"LD_PRELOAD": "",
|
||||
"TIMEOUT": "60",
|
||||
"CRAWL_MAX_CONCURRENT_SNAPSHOTS": "1",
|
||||
},
|
||||
)
|
||||
result = run_archivebox_cmd(
|
||||
[
|
||||
"add",
|
||||
"--depth=0",
|
||||
"--plugins=title",
|
||||
"https://example.com/?archivebox-title-env=1",
|
||||
],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
timeout=180,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
snapshot = Snapshot.objects.get()
|
||||
title_result = ArchiveResult.objects.select_related("process").get(snapshot=snapshot, plugin="title")
|
||||
process_env = title_result.process.env
|
||||
|
||||
assert crawl.config["PLUGINS"] == "title"
|
||||
assert snapshot.url == "https://example.com/?archivebox-title-env=1"
|
||||
assert snapshot.status == Snapshot.StatusChoices.SEALED
|
||||
assert snapshot.title == "Example Domain"
|
||||
assert title_result.status == ArchiveResult.StatusChoices.SUCCEEDED
|
||||
assert title_result.output_str == "Example Domain"
|
||||
assert title_result.process.exit_code == 0
|
||||
assert process_env["TITLE_ENABLED"] == "True"
|
||||
assert process_env["CHROME_ENABLED"] == "True"
|
||||
assert process_env["WGET_ENABLED"] == "False"
|
||||
assert process_env["ARCHIVEBOX_TEST_ARBITRARY_ENV"] == "preserved-user-env"
|
||||
assert process_env["LD_PRELOAD"] == ""
|
||||
assert "PLUGINS" not in process_env
|
||||
assert "SAVE_TITLE" not in process_env
|
||||
assert "SEARCH_BACKEND_ENGINE" not in process_env
|
||||
assert "SECRET_KEY" not in process_env
|
||||
assert "ADMIN_PASSWORD" not in process_env
|
||||
assert "PUBLIC_ADD_VIEW" not in process_env
|
||||
assert "URL_BLACKLIST" not in process_env
|
||||
assert "hook-env-secret-must-not-leak" not in json.dumps(process_env)
|
||||
assert "hook-env-admin-password-must-not-leak" not in json.dumps(process_env)
|
||||
|
||||
|
||||
def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(initialized_archive):
|
||||
@@ -505,12 +619,12 @@ def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(initi
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
snapshot_urls = set(Snapshot.objects.values_list("url", flat=True))
|
||||
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.retry_at is None
|
||||
assert crawl.urls == "https://example.com"
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert snapshot_urls == set()
|
||||
|
||||
run_queued_crawls(initialized_archive, env)
|
||||
|
||||
@@ -520,8 +634,8 @@ def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(initi
|
||||
|
||||
assert crawl.status == Crawl.StatusChoices.SEALED
|
||||
assert crawl.retry_at is None
|
||||
assert crawl.urls == "https://example.com"
|
||||
assert snapshot_urls == {Snapshot.INTERNAL_INPUT_URL}
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert snapshot_urls == set()
|
||||
|
||||
|
||||
def test_add_index_only_rejects_archivebox_internal_urls(initialized_archive):
|
||||
@@ -545,10 +659,12 @@ def test_add_index_only_rejects_archivebox_internal_urls(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
snapshot_urls = set(Snapshot.objects.values_list("url", flat=True))
|
||||
|
||||
assert crawl.urls == "\n".join(internal_urls)
|
||||
assert [json.loads(line) for line in crawl.urls.splitlines()] == [
|
||||
{"type": "CrawlSeed", "url": url, "depth": 0} for url in internal_urls
|
||||
]
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.retry_at is None
|
||||
assert snapshot_urls == {Snapshot.INTERNAL_INPUT_URL}
|
||||
assert snapshot_urls == set()
|
||||
|
||||
|
||||
def test_add_creates_crawl_record(initialized_archive):
|
||||
@@ -566,19 +682,21 @@ def test_add_creates_crawl_record(initialized_archive):
|
||||
assert crawl_count == 1
|
||||
|
||||
|
||||
def test_add_creates_internal_input_file(initialized_archive):
|
||||
"""Test that add stores submitted text under the root snapshot staticfile output."""
|
||||
def test_add_direct_url_creates_snapshot_without_internal_input_file(initialized_archive):
|
||||
"""Explicit URL args materialize real snapshots when the runner claims the crawl."""
|
||||
env = cli_env(disable_extractors=True)
|
||||
run_archivebox_cmd(
|
||||
["add", "--index-only", "--depth=0", "https://example.com"],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
)
|
||||
run_queued_crawls(initialized_archive, env)
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
source_content = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
assert source_content == "https://example.com"
|
||||
snapshot = Snapshot.objects.get()
|
||||
assert snapshot.url == "https://example.com"
|
||||
assert snapshot.depth == 0
|
||||
assert not (snapshot.output_dir / "staticfile" / "stdin.txt").exists()
|
||||
|
||||
|
||||
def test_add_multiple_urls_single_command(initialized_archive):
|
||||
@@ -594,11 +712,13 @@ def test_add_multiple_urls_single_command(initialized_archive):
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
snapshots = list(Snapshot.objects.order_by("url").values_list("url", "depth"))
|
||||
|
||||
assert crawl.urls == "https://example.com\nhttps://example.org"
|
||||
assert root_input == "https://example.com\nhttps://example.org"
|
||||
assert [json.loads(line) for line in crawl.urls.splitlines()] == [
|
||||
{"type": "CrawlSeed", "url": "https://example.com", "depth": 0},
|
||||
{"type": "CrawlSeed", "url": "https://example.org", "depth": 0},
|
||||
]
|
||||
assert snapshots == []
|
||||
|
||||
|
||||
def test_add_rejects_file_path_argument(initialized_archive):
|
||||
@@ -748,17 +868,15 @@ def test_add_duplicate_url_creates_separate_crawls(initialized_archive):
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
)
|
||||
run_queued_crawls(initialized_archive, env)
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl_count = Crawl.objects.count()
|
||||
root_inputs = [
|
||||
snapshot.output_dir.joinpath("staticfile", "stdin.txt").read_text(encoding="utf-8")
|
||||
for snapshot in Snapshot.objects.order_by("created_at")
|
||||
]
|
||||
snapshots = list(Snapshot.objects.order_by("created_at").values_list("url", "depth"))
|
||||
|
||||
# Each add creates a new crawl with its own queued work.
|
||||
assert crawl_count == 2
|
||||
assert root_inputs == ["https://example.com", "https://example.com"]
|
||||
assert snapshots == [("https://example.com", 0), ("https://example.com", 0)]
|
||||
|
||||
|
||||
def test_add_with_overwrite_flag(initialized_archive):
|
||||
@@ -873,29 +991,31 @@ def test_add_index_only_queues_crawl_without_starting_runner(initialized_archive
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
snapshots = list(Snapshot.objects.all())
|
||||
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.retry_at is None
|
||||
assert crawl.urls == "https://example.com"
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert snapshots == []
|
||||
|
||||
|
||||
def test_add_index_only_creates_only_internal_root_snapshot(initialized_archive):
|
||||
"""Test that index-only add creates the input root but not parsed child snapshots."""
|
||||
def test_add_index_only_creates_direct_url_snapshot(initialized_archive):
|
||||
"""Index-only add seeds explicit URL args for the runner to snapshot."""
|
||||
env = cli_env(disable_extractors=True)
|
||||
run_archivebox_cmd(
|
||||
["add", "--index-only", "--depth=0", "https://example.com"],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
)
|
||||
run_queued_crawls(initialized_archive, env)
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
root_snapshot = Snapshot.objects.get()
|
||||
snapshot = Snapshot.objects.get()
|
||||
|
||||
assert crawl.urls == "https://example.com"
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert snapshot.url == "https://example.com"
|
||||
assert snapshot.depth == 0
|
||||
|
||||
|
||||
def test_snapshot_create_sets_snapshot_timestamp(initialized_archive):
|
||||
@@ -1023,7 +1143,7 @@ def test_cli_add_real_urls_with_options_writes_inspectable_outputs(initialized_a
|
||||
processes = list(Process.objects.filter(process_type="hook").values_list("process_type", "status", "exit_code", "pwd", "cmd"))
|
||||
|
||||
assert real_flow_crawl is not None
|
||||
assert real_flow_crawl[0] == 1
|
||||
assert real_flow_crawl[0] == 0
|
||||
assert real_flow_crawl[1] == "real-flow,challenge"
|
||||
real_flow_config = real_flow_crawl[2] or {}
|
||||
assert real_flow_config["CRAWL_MAX_URLS"] == 2
|
||||
@@ -1036,7 +1156,7 @@ def test_cli_add_real_urls_with_options_writes_inspectable_outputs(initialized_a
|
||||
|
||||
snapshot_urls = {url for _id, url, _depth, _status, _title in snapshots}
|
||||
assert snapshot_urls >= {*wget_urls, chrome_url}
|
||||
assert all(depth == (0 if url == Snapshot.INTERNAL_INPUT_URL else 1) for _id, url, depth, _status, _title in snapshots)
|
||||
assert all(depth == 0 for _id, url, depth, _status, _title in snapshots)
|
||||
|
||||
by_url_plugin = {(url, plugin): status for url, plugin, status, _files, _size, _output in archive_results}
|
||||
assert by_url_plugin[("https://example.com", "wget")] == "succeeded"
|
||||
@@ -1126,15 +1246,14 @@ def test_cli_recursive_crawl_processes_discovered_html_urls(initialized_archive,
|
||||
.values_list("snapshot__url", "plugin", "status", "output_files"),
|
||||
)
|
||||
|
||||
assert crawl[0] == 3
|
||||
assert crawl[0] == 2
|
||||
assert crawl[1] == "recursive-flow"
|
||||
crawl_config = crawl[2] or {}
|
||||
assert crawl_config["CRAWL_MAX_URLS"] == 2
|
||||
assert crawl_config["CRAWL_MAX_SIZE"] == 50 * 1024 * 1024
|
||||
assert crawl_config.get("SNAPSHOT_MAX_SIZE", 0) == 0
|
||||
assert (Snapshot.INTERNAL_INPUT_URL, 0, "sealed") in snapshots
|
||||
assert (root_url, 1, "sealed") in snapshots
|
||||
assert any(url == child_url and depth == 2 and status == "sealed" for url, depth, status in snapshots)
|
||||
assert (root_url, 0, "sealed") in snapshots
|
||||
assert any(url == child_url and depth == 1 and status == "sealed" for url, depth, status in snapshots)
|
||||
|
||||
by_url_plugin = {(url, plugin): status for url, plugin, status, _files in archive_results}
|
||||
assert by_url_plugin[(root_url, "wget")] == "succeeded"
|
||||
|
||||
@@ -417,27 +417,41 @@ def test_crawl_multiple_urls_creates_multiple_snapshots(initialized_archive):
|
||||
assert "https://iana.org" in urls
|
||||
|
||||
|
||||
def test_crawl_from_file_creates_snapshot(initialized_archive):
|
||||
"""Test that crawl can create snapshots from a file of URLs."""
|
||||
def test_crawl_path_argument_is_rejected_but_stdin_file_contents_create_snapshot(initialized_archive):
|
||||
"""Local file paths are not URL args; users must pipe file contents through stdin."""
|
||||
env = cli_env(disable_extractors=True)
|
||||
|
||||
# Write URLs to a file
|
||||
urls_file = initialized_archive / "urls.txt"
|
||||
urls_file.write_text("https://example.com\n")
|
||||
urls_file.write_text("https://example.com\nhttps://iana.org\n", encoding="utf-8")
|
||||
|
||||
run_archivebox_cmd(
|
||||
path_result = run_archivebox_cmd(
|
||||
["crawl", "create", str(urls_file)],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
)
|
||||
assert path_result.returncode == 1
|
||||
assert "No URLs provided" in path_result.stderr
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
assert Crawl.objects.count() == 0
|
||||
assert Snapshot.objects.count() == 0
|
||||
|
||||
stdin_result = run_archivebox_cmd(
|
||||
["crawl", "create"],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
stdin=urls_file.read_text(encoding="utf-8"),
|
||||
check=True,
|
||||
)
|
||||
assert stdin_result.returncode == 0
|
||||
run_queued_crawls(initialized_archive, env)
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
snapshot = Snapshot.objects.first()
|
||||
urls = set(Snapshot.objects.values_list("url", flat=True))
|
||||
|
||||
# Should create at least one snapshot (the source file or the URL)
|
||||
assert snapshot is not None, "Should create at least one snapshot"
|
||||
assert "https://example.com" in urls
|
||||
assert "https://iana.org" in urls
|
||||
assert str(urls_file) not in urls
|
||||
|
||||
|
||||
def test_crawl_persists_input_urls_on_crawl(initialized_archive):
|
||||
|
||||
@@ -5,6 +5,7 @@ Verify init creates correct database schema, filesystem structure, and config.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
from django.utils import timezone
|
||||
from django.db import connections
|
||||
from django.db.migrations.recorder import MigrationRecorder
|
||||
@@ -14,6 +15,7 @@ from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.machine.models import Machine
|
||||
from archivebox.tests.conftest import run_queued_crawls, run_archivebox_cmd, cli_env
|
||||
from archivebox.tests.conftest import _set_test_source_pythonpath
|
||||
|
||||
from archivebox.tests.test_orm_helpers import use_archivebox_db
|
||||
|
||||
@@ -129,6 +131,23 @@ def test_init_sets_correct_file_permissions(tmp_path):
|
||||
assert oct(archive_dir.stat().st_mode)[-3:] in (get_config().OUTPUT_PERMISSIONS, DIR_PERMISSIONS)
|
||||
|
||||
|
||||
def test_init_creates_database_with_archivebox_permissions_despite_permissive_umask(tmp_path):
|
||||
"""SQLite must create index.sqlite3 with ArchiveBox's restrictive mode from first open."""
|
||||
env = cli_env(disable_extractors=True)
|
||||
_set_test_source_pythonpath(env)
|
||||
result = subprocess.run(
|
||||
["bash", "-lc", "umask 000; archivebox init"],
|
||||
cwd=tmp_path,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
assert oct((tmp_path / "index.sqlite3").stat().st_mode)[-3:] == get_config().OUTPUT_PERMISSIONS
|
||||
|
||||
|
||||
def test_init_is_idempotent(tmp_path):
|
||||
"""Test that running init multiple times is safe (idempotent)."""
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from archivebox.tests.conftest import run_archivebox_cmd
|
||||
|
||||
import pytest
|
||||
|
||||
from archivebox.cli.archivebox_install import ensure_data_dir_lib_symlink
|
||||
from archivebox.config.paths import get_machine_type
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.machine.models import Binary
|
||||
@@ -114,6 +116,9 @@ def test_install_from_empty_dir_initializes_collection(tmp_path):
|
||||
env = os.environ.copy()
|
||||
tmp_short = Path("/tmp") / f"abx-install-empty-{tmp_path.name}"
|
||||
tmp_short.mkdir(parents=True, exist_ok=True)
|
||||
stale_lib_dir = tmp_path / "lib"
|
||||
stale_lib_dir.mkdir()
|
||||
(stale_lib_dir / "stale.txt").write_text("stale")
|
||||
env.update(
|
||||
{
|
||||
"TMP_DIR": str(tmp_short),
|
||||
@@ -134,6 +139,8 @@ def test_install_from_empty_dir_initializes_collection(tmp_path):
|
||||
assert "Installing specific binaries: git" in output
|
||||
assert (tmp_path / "ArchiveBox.conf").is_file()
|
||||
assert (tmp_path / "index.sqlite3").is_file()
|
||||
assert (tmp_path / "lib").is_symlink()
|
||||
assert (tmp_path / "lib").resolve().is_dir()
|
||||
|
||||
with use_archivebox_db(tmp_path):
|
||||
assert Snapshot.objects.count() == 0
|
||||
@@ -141,6 +148,16 @@ def test_install_from_empty_dir_initializes_collection(tmp_path):
|
||||
assert Binary.objects.filter(status="installed", name="git").count() == 1
|
||||
|
||||
|
||||
def test_install_lib_symlink_preserves_scoped_data_lib_dir(tmp_path):
|
||||
"""DATA_DIR/lib/<machine_type> is a valid ABXPKG_LIB_DIR and must not be replaced by a parent symlink."""
|
||||
scoped_lib_dir = tmp_path / "lib" / get_machine_type()
|
||||
|
||||
assert ensure_data_dir_lib_symlink(tmp_path, scoped_lib_dir) is None
|
||||
assert (tmp_path / "lib").is_dir()
|
||||
assert not (tmp_path / "lib").is_symlink()
|
||||
assert scoped_lib_dir.is_dir()
|
||||
|
||||
|
||||
def test_install_updates_binary_table(initialized_archive):
|
||||
"""Test that install completes and only mutates dependency state."""
|
||||
env = os.environ.copy()
|
||||
@@ -174,3 +191,5 @@ def test_install_updates_binary_table(initialized_archive):
|
||||
assert snapshot_count == 0
|
||||
assert binary_counts.get("installed", 0) > 0
|
||||
assert installed_git == 1
|
||||
assert (initialized_archive / "lib").is_symlink()
|
||||
assert (initialized_archive / "lib").resolve().is_dir()
|
||||
@@ -566,7 +566,9 @@ class TestRunDaemonMode:
|
||||
if proc.poll() is not None:
|
||||
stdout = proc.stdout.read()
|
||||
stderr = proc.stderr.read()
|
||||
pytest.fail(f"daemon exited before starting runner: code={proc.returncode}\nstdout={stdout}\nstderr={stderr}")
|
||||
raise AssertionError(
|
||||
f"daemon exited before starting runner: code={proc.returncode}\nstdout={stdout}\nstderr={stderr}",
|
||||
)
|
||||
with use_archivebox_db(initialized_archive):
|
||||
started = Process.objects.filter(
|
||||
process_type=Process.TypeChoices.ORCHESTRATOR,
|
||||
|
||||
@@ -207,8 +207,7 @@ def test_reload_workers_use_current_interpreter_and_supervisord_managed_runner()
|
||||
|
||||
|
||||
def test_server_daemon_starts_real_plugin_owned_sonic_worker(archivebox_daemon_server):
|
||||
if shutil.which("sonic") is None:
|
||||
pytest.skip("sonic server binary is required for Sonic worker integration tests")
|
||||
assert shutil.which("sonic") is not None, "sonic server binary is required for Sonic worker integration tests"
|
||||
|
||||
server = archivebox_daemon_server(
|
||||
SEARCH_BACKEND_ENGINE="sonic",
|
||||
@@ -332,8 +331,7 @@ def test_sonic_worker_is_disabled_when_sonic_disabled(tmp_path):
|
||||
|
||||
|
||||
def test_sonic_daemon_event_handler_accepts_real_running_worker(archivebox_daemon_server):
|
||||
if shutil.which("sonic") is None:
|
||||
pytest.skip("sonic server binary is required for Sonic worker integration tests")
|
||||
assert shutil.which("sonic") is not None, "sonic server binary is required for Sonic worker integration tests"
|
||||
|
||||
from abx_dl.events import ProcessStdoutEvent
|
||||
from abx_dl.orchestrator import create_bus
|
||||
|
||||
@@ -233,6 +233,7 @@ def test_delete_after_real_add_page_and_rest_create_paths(client):
|
||||
assert run_due_crawl(ui_crawl, lock_seconds=10)
|
||||
ui_snapshot = ui_crawl.snapshot_set.get(url="https://example.com/delete-after-ui")
|
||||
assert ui_snapshot.delete_at is not None
|
||||
assert not ui_snapshot.output_dir.joinpath("staticfile", "stdin.txt").exists()
|
||||
|
||||
from archivebox.api.auth import get_or_create_api_token
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_run_snapshot_seals_descendant_when_crawl_max_size_is_reached(tmp_path):
|
||||
crawl = Crawl.objects.create(
|
||||
urls="https://example.com",
|
||||
config={
|
||||
"LIB_DIR": str(tmp_path / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(tmp_path / "lib"),
|
||||
"PLUGINS": "__archivebox_test_no_plugins__",
|
||||
"CHROME_BINARY": "",
|
||||
"CRAWL_MAX_SIZE": 16,
|
||||
@@ -292,4 +292,5 @@ def test_recursive_crawl_respects_max_urls(tmp_path, initialized_archive, recurs
|
||||
assert depth_counts.get(0, 0) == 1
|
||||
assert depth_counts.get(1, 0) == 3
|
||||
assert depth_counts.get(2, 0) == 0
|
||||
assert depth_counts.get(3, 0) == 0
|
||||
assert set(recursive_test_site["child_urls"]).issubset({url for url, depth, _parent in snapshot_rows if depth == 1})
|
||||
@@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
def _install_chrome(tmp_path, env):
|
||||
env["CHROME_ISOLATION"] = "snapshot"
|
||||
env["LIB_DIR"] = str(tmp_path / "lib")
|
||||
env["ABXPKG_LIB_DIR"] = str(tmp_path / "lib")
|
||||
install_process = run_archivebox_cmd(
|
||||
["install", "chrome"],
|
||||
cwd=tmp_path,
|
||||
@@ -22,7 +22,7 @@ def _install_chrome(tmp_path, env):
|
||||
timeout=600,
|
||||
)
|
||||
assert install_process.returncode == 0, install_process.stderr or install_process.stdout
|
||||
cached_browser = _find_cached_chrome(Path(env["LIB_DIR"]))
|
||||
cached_browser = _find_cached_chrome(Path(env["ABXPKG_LIB_DIR"]))
|
||||
if cached_browser is not None:
|
||||
env["CHROME_BINARY"] = str(cached_browser)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
|
||||
from archivebox.core.forms import TagField, TagWidget
|
||||
from archivebox.core.models import Tag
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_tag_field_parses_legacy_tag_input():
|
||||
field = TagField()
|
||||
|
||||
assert field.clean("alpha beta alpha") == ["alpha", "beta"]
|
||||
assert field.clean("alpha, beta, Alpha") == ["Alpha", "alpha", "beta"]
|
||||
assert field.clean('"alpha beta", gamma') == ["alpha beta", "gamma"]
|
||||
assert field.clean('"alpha,beta", gamma') == ["alpha,beta", "gamma"]
|
||||
assert field.clean('alpha "beta gamma"') == ["alpha", "beta gamma"]
|
||||
assert field.clean('"alpha,beta') == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_tag_widget_formats_real_tag_rows():
|
||||
tags = [
|
||||
Tag.objects.create(name="plain"),
|
||||
Tag.objects.create(name="two words"),
|
||||
Tag.objects.create(name="comma,tag"),
|
||||
]
|
||||
|
||||
rendered_value = TagWidget().format_value(tags)
|
||||
|
||||
assert rendered_value == '"comma,tag", "two words", plain'
|
||||
assert TagField().clean(rendered_value) == ["comma,tag", "plain", "two words"]
|
||||
@@ -89,12 +89,13 @@ def test_snapshot_payload_uses_crawl_chrome_dirs_by_default():
|
||||
config = payload["config"]
|
||||
other_config = other_payload["config"]
|
||||
|
||||
assert Path(config["CHROME_USER_DATA_DIR"]).is_relative_to(crawl.output_dir)
|
||||
assert Path(config["CHROME_DOWNLOADS_DIR"]).is_relative_to(crawl.output_dir)
|
||||
assert Path(config["CHROME_USER_DATA_DIR"]).name == "chrome_profile"
|
||||
assert Path(config["CHROME_DOWNLOADS_DIR"]).name == "chrome_downloads"
|
||||
assert Path(config["CHROME_USER_DATA_DIR"]) == Path(other_config["CHROME_USER_DATA_DIR"])
|
||||
assert Path(config["CHROME_DOWNLOADS_DIR"]) == Path(other_config["CHROME_DOWNLOADS_DIR"])
|
||||
personas_dir = Path(config["PERSONAS_DIR"])
|
||||
other_personas_dir = Path(other_config["PERSONAS_DIR"])
|
||||
assert personas_dir.is_relative_to(crawl.output_dir)
|
||||
assert personas_dir == persona.runtime_root_for_crawl(crawl).parent
|
||||
assert personas_dir == other_personas_dir
|
||||
assert personas_dir / config["ACTIVE_PERSONA"] / "chrome_profile" == persona.runtime_profile_dir_for_crawl(crawl)
|
||||
assert personas_dir / config["ACTIVE_PERSONA"] / "chrome_downloads" == persona.runtime_downloads_dir_for_crawl(crawl)
|
||||
assert crawl_downloads_sentinel.read_text() == "keep"
|
||||
assert config["ACTIVE_PERSONA"] == "RuntimePersona"
|
||||
assert Path(config["CRAWL_DIR"]) == crawl.output_dir
|
||||
@@ -130,12 +131,15 @@ def test_snapshot_payload_uses_snapshot_chrome_dirs_when_snapshot_isolated():
|
||||
config = payload["config"]
|
||||
other_config = other_payload["config"]
|
||||
|
||||
assert Path(config["CHROME_USER_DATA_DIR"]).is_relative_to(snapshot.output_dir)
|
||||
assert Path(config["CHROME_DOWNLOADS_DIR"]).is_relative_to(snapshot.output_dir)
|
||||
assert Path(other_config["CHROME_USER_DATA_DIR"]).is_relative_to(other_snapshot.output_dir)
|
||||
assert Path(other_config["CHROME_DOWNLOADS_DIR"]).is_relative_to(other_snapshot.output_dir)
|
||||
assert Path(config["CHROME_USER_DATA_DIR"]) != Path(other_config["CHROME_USER_DATA_DIR"])
|
||||
assert Path(config["CHROME_DOWNLOADS_DIR"]) != Path(other_config["CHROME_DOWNLOADS_DIR"])
|
||||
personas_dir = Path(config["PERSONAS_DIR"])
|
||||
other_personas_dir = Path(other_config["PERSONAS_DIR"])
|
||||
assert personas_dir.is_relative_to(snapshot.output_dir)
|
||||
assert other_personas_dir.is_relative_to(other_snapshot.output_dir)
|
||||
assert personas_dir == persona.runtime_root_for_snapshot(snapshot).parent
|
||||
assert other_personas_dir == persona.runtime_root_for_snapshot(other_snapshot).parent
|
||||
assert personas_dir != other_personas_dir
|
||||
assert personas_dir / config["ACTIVE_PERSONA"] / "chrome_profile" == persona.runtime_profile_dir_for_snapshot(snapshot)
|
||||
assert personas_dir / config["ACTIVE_PERSONA"] / "chrome_downloads" == persona.runtime_downloads_dir_for_snapshot(snapshot)
|
||||
assert Path(config["CRAWL_DIR"]) == crawl.output_dir
|
||||
assert Path(config["SNAP_DIR"]) == snapshot.output_dir
|
||||
|
||||
@@ -465,7 +469,7 @@ def test_machine_service_persists_only_derived_config_events(tmp_path, hermetic_
|
||||
# path / install cache — that's the security boundary that stops plugins
|
||||
# from rewriting arbitrary user config via events. So CHROME_USER_DATA_DIR
|
||||
# from the derived payload is dropped; WGET_BINARY made it in (inside
|
||||
# LIB_DIR) then the unset removed it; ABX_INSTALL_CACHE survives.
|
||||
# ABXPKG_LIB_DIR) then the unset removed it; ABX_INSTALL_CACHE survives.
|
||||
assert machine.config == {
|
||||
"ABX_INSTALL_CACHE": {"wget": "2026-03-24T00:00:00+00:00"},
|
||||
"ABX_PNPM_CACHE": "/tmp/pnpm-cache",
|
||||
@@ -483,8 +487,8 @@ def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path, he
|
||||
from archivebox.machine.models import Machine
|
||||
from archivebox.services.runner import CrawlRunner
|
||||
|
||||
resolved_lib_dir = get_config(include_machine=False).LIB_DIR
|
||||
assert resolved_lib_dir == hermetic_lib_dir, f"LIB_DIR override not applied: {resolved_lib_dir!r} != {hermetic_lib_dir!r}"
|
||||
resolved_lib_dir = get_config(include_machine=False).ABXPKG_LIB_DIR
|
||||
assert resolved_lib_dir == hermetic_lib_dir, f"ABXPKG_LIB_DIR override not applied: {resolved_lib_dir!r} != {hermetic_lib_dir!r}"
|
||||
|
||||
wget_binary = resolved_lib_dir / "bin" / "wget"
|
||||
wget_binary.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
@@ -517,8 +521,8 @@ def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path, he
|
||||
runner = CrawlRunner(crawl)
|
||||
snapshot_ids = runner.load_run_state()
|
||||
|
||||
# ``derived_config`` is Machine.config sanitized against LIB_DIR. Binary
|
||||
# paths outside LIB_DIR drop out (YTDLP_BINARY → ``/tmp/...``); the
|
||||
# ``derived_config`` is Machine.config sanitized against ABXPKG_LIB_DIR. Binary
|
||||
# paths outside ABXPKG_LIB_DIR drop out (YTDLP_BINARY → ``/tmp/...``); the
|
||||
# ArchiveBox.conf mirror values (CHROME_ISOLATION, CHROME_USER_DATA_DIR,
|
||||
# ABX_INSTALL_CACHE) survive so plugin hooks see the same runtime cache
|
||||
# the user/runner persisted.
|
||||
@@ -528,7 +532,7 @@ def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path, he
|
||||
"CHROME_ISOLATION": "snapshot",
|
||||
"CHROME_USER_DATA_DIR": "/tmp/stale-profile",
|
||||
}
|
||||
assert runner.base_config["LIB_DIR"] == resolved_lib_dir
|
||||
assert runner.base_config["ABXPKG_LIB_DIR"] == resolved_lib_dir
|
||||
assert runner.base_config["CHROME_KEEPALIVE"] is False
|
||||
assert runner.selected_plugins == ["__archivebox_test_no_plugins__"]
|
||||
assert Snapshot.objects.filter(id__in=snapshot_ids, crawl=crawl, url="https://example.com").count() == 1
|
||||
@@ -546,7 +550,7 @@ def test_crawl_runner_empty_plugin_selection_emits_lifecycle_and_seals_crawl(tmp
|
||||
crawl = Crawl.objects.create(
|
||||
urls="https://example.com",
|
||||
config={
|
||||
"LIB_DIR": str(tmp_path / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(tmp_path / "lib"),
|
||||
"PLUGINS": "__archivebox_test_no_plugins__",
|
||||
"CHROME_BINARY": "",
|
||||
},
|
||||
@@ -694,7 +698,7 @@ def test_run_pending_crawls_processes_queued_crawl_before_missing_binary_backlog
|
||||
crawl = Crawl.objects.create(
|
||||
urls="https://example.com",
|
||||
config={
|
||||
"LIB_DIR": str(tmp_path / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(tmp_path / "lib"),
|
||||
"PLUGINS": "__archivebox_test_no_plugins__",
|
||||
"CHROME_BINARY": "",
|
||||
},
|
||||
|
||||
@@ -80,6 +80,9 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
|
||||
assert queued_state["retry_at"] is not None
|
||||
assert queued_state["config"]["PLUGINS"] == "wget,parse_html_urls"
|
||||
assert queued_state["config"]["URL_DENYLIST"] == "/contact$"
|
||||
# add --bg seeds Crawl.urls as CrawlSeed JSONL and returns; the runner
|
||||
# materializes Snapshot rows + applies URL_DENYLIST when it claims the
|
||||
# crawl, not at add time. The post-run assertions below verify those.
|
||||
assert queued_state["snapshots"] == []
|
||||
|
||||
_cmd_result = run_archivebox_cmd(
|
||||
|
||||
@@ -766,7 +766,7 @@ print(json.dumps({
|
||||
hook_path,
|
||||
output_dir,
|
||||
config={
|
||||
"LIB_DIR": str(lib_dir),
|
||||
"ABXPKG_LIB_DIR": str(lib_dir),
|
||||
"NODE_PATH": configured_node_path,
|
||||
},
|
||||
timeout=10,
|
||||
@@ -812,7 +812,7 @@ print(json.dumps({
|
||||
hook_path,
|
||||
output_dir,
|
||||
config={
|
||||
"LIB_DIR": str(tmp_path / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(tmp_path / "lib"),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -154,7 +154,7 @@ class TestMachineModel:
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
|
||||
Machine.current() # Ensure machine exists
|
||||
wget_path = CONSTANTS.DEFAULT_LIB_DIR / "wget"
|
||||
wget_path = CONSTANTS.DEFAULT_ABXPKG_LIB_DIR / "wget"
|
||||
wget_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
wget_path.write_text("#!/bin/sh\n")
|
||||
cleanup_paths.append(wget_path)
|
||||
@@ -179,7 +179,7 @@ class TestMachineModel:
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
|
||||
Machine.current() # Ensure machine exists
|
||||
wget_path = CONSTANTS.DEFAULT_LIB_DIR / "wget"
|
||||
wget_path = CONSTANTS.DEFAULT_ABXPKG_LIB_DIR / "wget"
|
||||
wget_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
wget_path.write_text("#!/bin/sh\n")
|
||||
cleanup_paths.append(wget_path)
|
||||
@@ -209,12 +209,12 @@ class TestMachineModel:
|
||||
``Machine.config`` is the file ↔ DB mirror of ``ArchiveBox.conf``, so
|
||||
non-binary keys (``CHROME_ISOLATION``, ``CHROMIUM_VERSION``, etc.) are
|
||||
preserved on read. Only ``_BINARY`` paths get validated against
|
||||
``LIB_DIR`` and dropped when stale/missing.
|
||||
``ABXPKG_LIB_DIR`` and dropped when stale/missing.
|
||||
"""
|
||||
import archivebox.machine.models as models
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
|
||||
active_lib_dir = CONSTANTS.DEFAULT_LIB_DIR
|
||||
active_lib_dir = CONSTANTS.DEFAULT_ABXPKG_LIB_DIR
|
||||
active_lib_dir.mkdir(parents=True, exist_ok=True)
|
||||
chrome_path = active_lib_dir / "chromium"
|
||||
node_path = active_lib_dir / "node"
|
||||
@@ -239,7 +239,7 @@ class TestMachineModel:
|
||||
|
||||
refreshed = Machine.current(refresh=True)
|
||||
|
||||
# Valid binary paths inside LIB_DIR survive.
|
||||
# Valid binary paths inside ABXPKG_LIB_DIR survive.
|
||||
assert refreshed.config.get("CHROME_BINARY") == str(chrome_path)
|
||||
assert refreshed.config.get("NODE_BINARY") == str(node_path)
|
||||
# Non-binary mirror keys survive — they belong to ArchiveBox.conf.
|
||||
@@ -247,7 +247,7 @@ class TestMachineModel:
|
||||
assert refreshed.config.get("CHROME_ISOLATION") == "snapshot"
|
||||
assert refreshed.config.get("CHROME_USER_DATA_DIR") == "/tmp/profile"
|
||||
assert refreshed.config.get("CHROMIUM_VERSION") == "123.4.5"
|
||||
# Stale binary paths get dropped: YTDLP_BINARY outside LIB_DIR,
|
||||
# Stale binary paths get dropped: YTDLP_BINARY outside ABXPKG_LIB_DIR,
|
||||
# WGET_BINARY path doesn't exist.
|
||||
assert "YTDLP_BINARY" not in refreshed.config
|
||||
assert "WGET_BINARY" not in refreshed.config
|
||||
@@ -262,7 +262,7 @@ class TestMachineModel:
|
||||
import archivebox.machine.models as models
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
lib_dir = get_config(include_machine=False).LIB_DIR
|
||||
lib_dir = get_config(include_machine=False).ABXPKG_LIB_DIR
|
||||
chrome_path = lib_dir / "chromium"
|
||||
chrome_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
chrome_path.write_text("#!/bin/sh\n")
|
||||
@@ -337,9 +337,9 @@ class TestBinaryModel:
|
||||
"""Binary.is_valid should be True for installed binaries with a resolved path."""
|
||||
binary = Binary.objects.create(
|
||||
machine=self.machine,
|
||||
name="wget",
|
||||
abspath="/usr/bin/wget",
|
||||
version="1.21",
|
||||
name="python",
|
||||
abspath=sys.executable,
|
||||
version=f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
status=Binary.StatusChoices.INSTALLED,
|
||||
)
|
||||
|
||||
@@ -348,21 +348,21 @@ class TestBinaryModel:
|
||||
def test_binary_manager_get_valid_binary(self):
|
||||
"""BinaryManager.get_valid_binary() should find valid binaries."""
|
||||
# Create invalid binary (no abspath)
|
||||
Binary.objects.create(machine=self.machine, name="wget")
|
||||
Binary.objects.create(machine=self.machine, name="python")
|
||||
|
||||
# Create valid binary
|
||||
Binary.objects.create(
|
||||
machine=self.machine,
|
||||
name="wget",
|
||||
abspath="/usr/bin/wget",
|
||||
version="1.21",
|
||||
name="python",
|
||||
abspath=sys.executable,
|
||||
version=f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
status=Binary.StatusChoices.INSTALLED,
|
||||
)
|
||||
|
||||
result = cast(BinaryManager, Binary.objects).get_valid_binary("wget")
|
||||
result = cast(BinaryManager, Binary.objects).get_valid_binary("python")
|
||||
|
||||
assert result is not None
|
||||
assert result.abspath == "/usr/bin/wget"
|
||||
assert result.abspath == sys.executable
|
||||
|
||||
def test_binary_update_and_requeue(self):
|
||||
"""Binary.update_and_requeue() should update fields and save."""
|
||||
|
||||
@@ -37,7 +37,6 @@ def _runtime_env(data_dir: Path, bin_dir: Path) -> dict[str, str]:
|
||||
os.environ.get("PATH", ""),
|
||||
]
|
||||
return {
|
||||
"LIB_DIR": str(data_dir / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(data_dir / "lib"),
|
||||
"LITEPARSE_ENABLED": "True",
|
||||
"TIMEOUT": "180",
|
||||
|
||||
@@ -38,13 +38,15 @@ def test_persona_prepare_runtime_for_crawl_clones_and_cleans_profile(initialized
|
||||
)
|
||||
|
||||
runtime_root = persona.runtime_root_for_crawl(crawl)
|
||||
runtime_profile = Path(overrides['CHROME_USER_DATA_DIR'])
|
||||
runtime_downloads = Path(overrides['CHROME_DOWNLOADS_DIR'])
|
||||
runtime_profile = persona.runtime_profile_dir_for_crawl(crawl)
|
||||
runtime_downloads = persona.runtime_downloads_dir_for_crawl(crawl)
|
||||
|
||||
print(json.dumps({
|
||||
'runtime_root_exists': runtime_root.exists(),
|
||||
'runtime_profile_exists': runtime_profile.exists(),
|
||||
'runtime_downloads_exists': runtime_downloads.exists(),
|
||||
'runtime_personas_dir': overrides['PERSONAS_DIR'],
|
||||
'active_persona': overrides['ACTIVE_PERSONA'],
|
||||
'preferences_copied': (runtime_profile / 'Default' / 'Preferences').exists(),
|
||||
'singleton_removed': not (runtime_profile / 'SingletonLock').exists(),
|
||||
'cache_removed': not (runtime_profile / 'Default' / 'GPUCache').exists(),
|
||||
@@ -63,6 +65,8 @@ def test_persona_prepare_runtime_for_crawl_clones_and_cleans_profile(initialized
|
||||
assert payload["runtime_root_exists"] is True
|
||||
assert payload["runtime_profile_exists"] is True
|
||||
assert payload["runtime_downloads_exists"] is True
|
||||
assert payload["runtime_personas_dir"].endswith("/.persona")
|
||||
assert payload["active_persona"] == "Default"
|
||||
assert payload["preferences_copied"] is True
|
||||
assert payload["singleton_removed"] is True
|
||||
assert payload["cache_removed"] is True
|
||||
|
||||
@@ -427,12 +427,11 @@ def test_add_archivewebpage_installs_required_chrome_dependency(initialized_arch
|
||||
"SHOW_PROGRESS": "false",
|
||||
"TIMEOUT": "120",
|
||||
"ABXPKG_INSTALL_TIMEOUT": "900",
|
||||
"LIB_DIR": str(initialized_archive / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(initialized_archive / "lib"),
|
||||
"CHROME_HEADLESS": "true",
|
||||
"CHROME_SANDBOX": "false",
|
||||
"CHROME_ISOLATION": "snapshot",
|
||||
"CHROME_EXTENSIONS_DIR": str(initialized_archive / "lib/chromewebstore/extensions"),
|
||||
"CHROMEWEBSTORE_EXTENSIONS_DIR": str(initialized_archive / "lib/chromewebstore/extensions"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -478,7 +477,6 @@ def test_add_archivewebpage_installs_required_chrome_dependency(initialized_arch
|
||||
|
||||
assert "chromium" in binaries
|
||||
assert binaries["chromium"]["status"] == Binary.StatusChoices.INSTALLED
|
||||
assert binaries["chromium"]["binprovider"] == "puppeteer"
|
||||
assert Path(binaries["chromium"]["abspath"]).exists()
|
||||
chromium_version_parts = [int(part) for part in binaries["chromium"]["version"].split(".")[:3]]
|
||||
assert chromium_version_parts >= [149, 0, 0]
|
||||
@@ -507,7 +505,7 @@ def test_add_archivewebpage_installs_required_chrome_dependency(initialized_arch
|
||||
if process_type == Process.TypeChoices.HOOK and "chrome_launch" in str(cmd)
|
||||
]
|
||||
assert chrome_hook_envs
|
||||
assert all("{LIB_DIR}" not in str(env) for env in chrome_hook_envs)
|
||||
assert all("{ABXPKG_LIB_DIR}" not in str(env) for env in chrome_hook_envs)
|
||||
assert any(process_type == Process.TypeChoices.BINARY for process_type, _status, _exit_code, _cmd, _env in process_rows)
|
||||
assert all(
|
||||
status == Process.StatusChoices.EXITED and exit_code == 0
|
||||
@@ -532,7 +530,6 @@ def test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel(initia
|
||||
{
|
||||
"USE_COLOR": "false",
|
||||
"SHOW_PROGRESS": "false",
|
||||
"LIB_DIR": str(initialized_archive / "lib"),
|
||||
"ABXPKG_LIB_DIR": str(initialized_archive / "lib"),
|
||||
"TIMEOUT": "90",
|
||||
"ABXPKG_INSTALL_TIMEOUT": "900",
|
||||
|
||||
@@ -479,6 +479,15 @@ class TestPublicIndexSearch:
|
||||
assert response.status_code == 200
|
||||
assert b"archivebox-search-stream-status" in response.content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_search_stream_preserves_search_form_dom(self, client, public_snapshot):
|
||||
response = client.get("/public/", {"q": "Public Example"}, HTTP_HOST=WEB_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"replaceRegionFromDocument(doc, '.public-snapshot-count')" in response.content
|
||||
assert b"replaceRegionFromDocument(doc, '#table-bookmarks tbody')" in response.content
|
||||
assert b"currentList.replaceWith(nextList)" not in response.content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_search_stream_populates_public_results_cache(self, client, public_snapshot):
|
||||
search_params = {"q": "Public Example", "search_mode": "meta"}
|
||||
@@ -562,6 +571,47 @@ class TestPublicIndexSearch:
|
||||
assert "last »" not in last_content
|
||||
assert "private-page-test" not in last_content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_index_preview_respects_root_relative_screenshot_output(self, client, public_snapshot):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
ArchiveResult.objects.create(
|
||||
snapshot=public_snapshot,
|
||||
plugin="screenshot",
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
output_files={
|
||||
"screenshot.png": {"size": 3, "root_relative": True},
|
||||
},
|
||||
output_str="screenshot.png",
|
||||
)
|
||||
|
||||
response = client.get("/public/", HTTP_HOST=WEB_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "/screenshot.png" in content
|
||||
assert "screenshot/screenshot.png" not in content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_index_preview_respects_plugin_relative_screenshot_output(self, client, public_snapshot):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
ArchiveResult.objects.create(
|
||||
snapshot=public_snapshot,
|
||||
plugin="screenshot",
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
output_files={
|
||||
"screenshot.png": {"size": 3},
|
||||
},
|
||||
output_str="screenshot.png",
|
||||
)
|
||||
|
||||
response = client.get("/public/", HTTP_HOST=WEB_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "screenshot/screenshot.png" in content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_index_preview_falls_back_to_extension_screenshots(self, client, public_snapshot):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
@@ -581,12 +631,21 @@ class TestPublicIndexSearch:
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "screenshot/screenshot.png" in content
|
||||
first = content.index("chrome_extension_screenshot/screenshot-1.png")
|
||||
second = content.index("chrome_extension_screenshot/screenshot.png")
|
||||
assert first < second
|
||||
assert "screenshot/screenshot.png" not in content
|
||||
assert "chrome_extension_screenshot/screenshot-2.png" not in content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_index_snapshot_without_preview_renders_placeholder(self, client, public_snapshot):
|
||||
response = client.get("/public/", HTTP_HOST=WEB_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "snapshot-preview-empty" in content
|
||||
assert "screenshot/screenshot.png" not in content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_index_pending_snapshot_uses_small_preview_spinner(self, client, crawl):
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
@@ -297,7 +297,6 @@ def browser_runtime(initialized_archive: Path):
|
||||
env = cli_env(
|
||||
ABXPKG_INSTALL_TIMEOUT="900",
|
||||
ABXPKG_MIN_RELEASE_AGE="0",
|
||||
LIB_DIR=str(shared_lib),
|
||||
ABXPKG_LIB_DIR=str(shared_lib),
|
||||
CHROME_HEADLESS="True",
|
||||
CHROME_SANDBOX="False",
|
||||
@@ -776,11 +775,10 @@ def test_archivewebpage_wacz_preview_serves_real_capture_frame(initialized_archi
|
||||
ARCHIVEWEBPAGE_TIMEOUT="90",
|
||||
TIMEOUT="90",
|
||||
)
|
||||
env["LIB_DIR"] = str(browser_runtime["lib_dir"])
|
||||
env["ABXPKG_LIB_DIR"] = str(browser_runtime["lib_dir"])
|
||||
env["NODE_PATH"] = str(browser_runtime["node_modules_dir"])
|
||||
env["NODE_MODULES_DIR"] = str(browser_runtime["node_modules_dir"])
|
||||
env["CHROME_EXTENSIONS_DIR"] = str(
|
||||
env["CHROMEWEBSTORE_EXTENSIONS_DIR"] = str(
|
||||
browser_runtime["lib_dir"] / "chromewebstore" / "extensions",
|
||||
)
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def _require_sonic_binary() -> None:
|
||||
if shutil.which("sonic") is None:
|
||||
pytest.skip("sonic server binary is required for Sonic worker takeover tests")
|
||||
assert shutil.which("sonic") is not None, "sonic server binary is required for Sonic worker takeover tests"
|
||||
|
||||
|
||||
def _archive_pages_for_sqlite_reindexing(data_dir: Path, env: dict[str, str], root_url: str) -> None:
|
||||
|
||||
@@ -99,6 +99,61 @@ def test_add_view_admin_renders_plugin_config_grid(client, admin_user, monkeypat
|
||||
assert b"plugin_config__wget__WGET_ENABLED" not in response.content
|
||||
|
||||
|
||||
def test_add_view_staff_user_cannot_override_raw_or_plugin_config(client):
|
||||
staff_user = User.objects.create_user(
|
||||
username="addviewstaff",
|
||||
email="addviewstaff@test.com",
|
||||
password="testpassword",
|
||||
is_staff=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
client.force_login(staff_user)
|
||||
|
||||
response = client.get(reverse("add"), HTTP_HOST=ADMIN_HOST)
|
||||
assert response.status_code == 200
|
||||
assert response.context["can_override_crawl_config"] is False
|
||||
assert response.context["form"].plugin_groups == []
|
||||
assert b"plugin_config__wget__WGET_TIMEOUT" not in response.content
|
||||
assert b"Custom config overrides" not in response.content
|
||||
|
||||
response = client.post(
|
||||
reverse("add"),
|
||||
data={
|
||||
"url": "https://example.com/staff-config",
|
||||
"tag": "",
|
||||
"depth": "0",
|
||||
"max_urls": "0",
|
||||
"crawl_max_size": "0",
|
||||
"crawl_timeout": "0",
|
||||
"timeout": "60",
|
||||
"snapshot_max_size": "0",
|
||||
"delete_after": "0",
|
||||
"crawl_max_concurrent_snapshots": "1",
|
||||
"url_filters_allowlist": "",
|
||||
"url_filters_denylist": "",
|
||||
"notes": "",
|
||||
"schedule": "daily",
|
||||
"persona": "Default",
|
||||
"permissions": "public",
|
||||
"start_paused": "on",
|
||||
"main_plugins": ["wget"],
|
||||
"plugin_config__wget__WGET_TIMEOUT": "77",
|
||||
"config": '{"WGET_TIMEOUT": 77, "YTDLP_ARGS_EXTRA": ["--exec", "touch /tmp/owned"]}',
|
||||
},
|
||||
HTTP_HOST=ADMIN_HOST,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
crawl = Crawl.objects.order_by("-created_at").first()
|
||||
assert crawl is not None
|
||||
assert crawl.created_by == staff_user
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.schedule is None
|
||||
assert crawl.config.get("PLUGINS", "") == ""
|
||||
assert crawl.config.get("WGET_TIMEOUT") != 77
|
||||
assert crawl.config.get("YTDLP_ARGS_EXTRA") != ["--exec", "touch /tmp/owned"]
|
||||
|
||||
|
||||
def test_add_view_embeds_selected_persona_config_for_ui_hydration(client, admin_user, monkeypatch):
|
||||
monkeypatch.setenv("PUBLIC_ADD_VIEW", "true")
|
||||
client.force_login(admin_user)
|
||||
@@ -215,6 +270,43 @@ def test_add_view_creates_crawl_with_tag_and_url_filter_overrides(client, admin_
|
||||
assert crawl.config["ONLY_NEW"] is True
|
||||
|
||||
|
||||
def test_add_view_sanitizes_crawl_notes_before_safe_update(client, admin_user):
|
||||
client.force_login(admin_user)
|
||||
malicious_notes = "</script><script id=add-notes-xss>window.__archivebox_add_notes_xss__=1</script>"
|
||||
|
||||
response = client.post(
|
||||
reverse("add"),
|
||||
data={
|
||||
"url": "https://example.com/notes-xss",
|
||||
"tag": "",
|
||||
"depth": "0",
|
||||
"max_urls": "1",
|
||||
"crawl_max_size": "0",
|
||||
"crawl_timeout": "0",
|
||||
"timeout": "",
|
||||
"snapshot_max_size": "0",
|
||||
"delete_after": "0",
|
||||
"crawl_max_concurrent_snapshots": "1",
|
||||
"url_filters_allowlist": "",
|
||||
"url_filters_denylist": "",
|
||||
"notes": malicious_notes,
|
||||
"schedule": "",
|
||||
"persona": "Default",
|
||||
"permissions": "public",
|
||||
"start_paused": "",
|
||||
"config": "{}",
|
||||
},
|
||||
HTTP_HOST=ADMIN_HOST,
|
||||
)
|
||||
|
||||
assert response.status_code == 302, response.context["form"].errors if response.context else response.content.decode()
|
||||
crawl = Crawl.objects.order_by("-created_at").first()
|
||||
assert crawl is not None
|
||||
assert crawl.notes == "window.__archivebox_add_notes_xss__=1"
|
||||
assert "<script" not in crawl.notes
|
||||
assert "</script>" not in crawl.notes
|
||||
|
||||
|
||||
def test_add_view_unchecked_only_new_sets_crawl_override(client, admin_user, monkeypatch):
|
||||
monkeypatch.setenv("PUBLIC_ADD_VIEW", "true")
|
||||
client.force_login(admin_user)
|
||||
@@ -283,9 +375,9 @@ def test_add_view_selected_persona_wins_over_stale_config_override(client, admin
|
||||
assert crawl.persona_id == private_persona.id
|
||||
assert "ACTIVE_PERSONA" not in crawl.config
|
||||
assert crawl.resolve_persona() == private_persona
|
||||
snapshot = Snapshot.objects.create(url="https://example.com/private", crawl=crawl)
|
||||
runner = CrawlRunner(crawl, selected_plugins=["title"], show_progress=False)
|
||||
runner.load_run_state()
|
||||
snapshot_ids = runner.load_run_state()
|
||||
snapshot = crawl.snapshot_set.get(id=snapshot_ids[0], url="https://example.com/private")
|
||||
runtime_config = runner.load_snapshot_payload(str(snapshot.id))["config"]
|
||||
assert runtime_config["ACTIVE_PERSONA"] == "Private"
|
||||
assert runtime_config["COOKIES_FILE"] == str(private_cookies_file)
|
||||
@@ -418,10 +510,8 @@ def test_add_view_queues_crawl_for_background_runner(client, admin_user, monkeyp
|
||||
assert crawl is not None
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.retry_at is not None
|
||||
assert crawl.urls == "https://example.com"
|
||||
root_snapshot = crawl.snapshot_set.get()
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") == "https://example.com"
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com", "depth": 0}
|
||||
assert crawl.snapshot_set.count() == 0
|
||||
|
||||
|
||||
def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, admin_user, monkeypatch):
|
||||
@@ -455,10 +545,8 @@ def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, ad
|
||||
assert crawl is not None
|
||||
assert crawl.status == Crawl.StatusChoices.PAUSED
|
||||
assert crawl.retry_at == RETRY_AT_MAX
|
||||
assert crawl.urls == "https://example.com/paused"
|
||||
root_snapshot = crawl.snapshot_set.get()
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") == "https://example.com/paused"
|
||||
assert json.loads(crawl.urls) == {"type": "CrawlSeed", "url": "https://example.com/paused", "depth": 0}
|
||||
assert crawl.snapshot_set.count() == 0
|
||||
assert crawl.config.get("INDEX_ONLY") is not True
|
||||
|
||||
|
||||
@@ -510,9 +598,7 @@ def test_add_view_extracts_urls_from_mixed_text_input(client, admin_user, monkey
|
||||
],
|
||||
)
|
||||
assert crawl.urls == expected_input
|
||||
root_snapshot = crawl.snapshot_set.get()
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") == expected_input
|
||||
assert crawl.snapshot_set.count() == 0
|
||||
|
||||
|
||||
def test_add_view_trims_trailing_punctuation_from_markdown_urls(client, admin_user, monkeypatch):
|
||||
@@ -557,9 +643,7 @@ def test_add_view_trims_trailing_punctuation_from_markdown_urls(client, admin_us
|
||||
],
|
||||
)
|
||||
assert crawl.urls == expected_input
|
||||
root_snapshot = crawl.snapshot_set.get()
|
||||
assert root_snapshot.url == Snapshot.INTERNAL_INPUT_URL
|
||||
assert (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") == expected_input
|
||||
assert crawl.snapshot_set.count() == 0
|
||||
|
||||
|
||||
def test_add_view_exposes_api_token_for_tag_widget_autocomplete(client, admin_user, monkeypatch):
|
||||
|
||||
@@ -263,7 +263,11 @@ def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_s
|
||||
crawl = Crawl.objects.order_by("-created_at").first()
|
||||
assert crawl is not None
|
||||
assert crawl.tags_str == "restart-supervised-runner"
|
||||
assert crawl.urls == recursive_test_site["root_url"]
|
||||
assert json.loads(crawl.urls) == {
|
||||
"type": "CrawlSeed",
|
||||
"url": recursive_test_site["root_url"],
|
||||
"depth": 0,
|
||||
}
|
||||
finally:
|
||||
stop_server(tmp_path)
|
||||
|
||||
@@ -391,12 +395,18 @@ def test_public_add_view_import_text_formats_preserve_metadata_and_resume_withou
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code in (302, 303), response.text
|
||||
with use_archivebox_db(tmp_path):
|
||||
crawl = Crawl.objects.order_by("-created_at").first()
|
||||
assert crawl is not None
|
||||
root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL)
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
assert crawl.urls == source_text
|
||||
deadline = time.time() + 60
|
||||
root_input = None
|
||||
while time.time() < deadline:
|
||||
with use_archivebox_db(tmp_path):
|
||||
crawl = Crawl.objects.order_by("-created_at").first()
|
||||
assert crawl is not None
|
||||
assert crawl.urls == source_text
|
||||
root_snapshot = crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).first()
|
||||
if root_snapshot:
|
||||
root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
|
||||
break
|
||||
time.sleep(1)
|
||||
assert root_input == source_text
|
||||
|
||||
wait_for_import_processing(tmp_path, expected_urls)
|
||||
|
||||
@@ -32,6 +32,111 @@ def test_snapshot_changelist_uses_stable_ordering_without_unordered_paginator_wa
|
||||
assert b"Searching matching snapshots..." in response.content
|
||||
|
||||
|
||||
def test_snapshot_admin_tag_editor_escapes_tag_json_script_breakout(admin_client, snapshot):
|
||||
from archivebox.core.models import Tag
|
||||
|
||||
tag = Tag.objects.create(name="legacy-safe-tag")
|
||||
snapshot.tags.add(tag)
|
||||
malicious_name = '</script><script id="archivebox-tag-xss">window.__archivebox_tag_xss__=1</script>'
|
||||
Tag.objects.filter(pk=tag.pk).update(name=malicious_name)
|
||||
|
||||
response = admin_client.get(reverse("admin:core_snapshot_change", args=[snapshot.pk]), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
body = response.content
|
||||
|
||||
assert response.status_code == 200
|
||||
assert malicious_name.encode() not in body
|
||||
assert b'<script id="archivebox-tag-xss">' not in body
|
||||
assert b'\\u003C/script\\u003E\\u003Cscript id=\\"archivebox-tag-xss\\"\\u003E' in body
|
||||
assert b"</script><script id="archivebox-tag-xss">" in body
|
||||
|
||||
|
||||
def test_snapshot_admin_archive_results_escape_extractor_output(admin_client, snapshot):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
payload = '<img src=x onerror="window.__archivebox_archiveresult_xss__=1">'
|
||||
ArchiveResult.objects.create(
|
||||
snapshot=snapshot,
|
||||
plugin="title",
|
||||
hook_name="on_Snapshot__54_title.js",
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
output_str=payload,
|
||||
)
|
||||
|
||||
response = admin_client.get(reverse("admin:core_snapshot_change", args=[snapshot.pk]), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
body = response.content
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload.encode() not in body
|
||||
assert b'<img src=x onerror="window.__archivebox_archiveresult_xss__=1">' not in body
|
||||
assert b"<img src=x onerror="window.__archivebox_archiveresult_xss__=1">" in body
|
||||
|
||||
|
||||
def test_snapshot_admin_archive_result_table_escapes_legacy_string_fields(admin_client, snapshot):
|
||||
from uuid import uuid4
|
||||
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.machine.models import Binary, Machine, Process
|
||||
|
||||
machine = Machine.objects.create(
|
||||
guid=f"xss-machine-{uuid4()}",
|
||||
hostname='<script id="machine-xss">x</script>',
|
||||
hw_in_docker=False,
|
||||
hw_in_vm=False,
|
||||
hw_manufacturer="Test",
|
||||
hw_product="Test Product",
|
||||
hw_uuid=f"xss-hw-{uuid4()}",
|
||||
os_arch="arm64",
|
||||
os_family="darwin",
|
||||
os_platform="macOS",
|
||||
os_release="14.0",
|
||||
os_kernel="Darwin",
|
||||
stats={},
|
||||
config={},
|
||||
)
|
||||
binary = Binary.objects.create(
|
||||
machine=machine,
|
||||
name="staticfile",
|
||||
abspath="/usr/bin/staticfile",
|
||||
version='<b id="version-xss">v</b>',
|
||||
binprovider="env",
|
||||
binproviders="env",
|
||||
status=Binary.StatusChoices.INSTALLED,
|
||||
)
|
||||
process = Process.objects.create(
|
||||
machine=machine,
|
||||
binary=binary,
|
||||
process_type=Process.TypeChoices.HOOK,
|
||||
pwd='/tmp/archivebox"><script id="pwd-xss">x</script>',
|
||||
cmd=["staticfile"],
|
||||
status=Process.StatusChoices.EXITED,
|
||||
)
|
||||
result = ArchiveResult.objects.create(
|
||||
snapshot=snapshot,
|
||||
plugin="staticfile",
|
||||
hook_name="on_Snapshot__00_staticfile.py",
|
||||
process=process,
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
output_files={'evil"><script id="file-xss">x</script>.txt': {"size": 12, "mimetype": "text/plain"}},
|
||||
output_str='staticfile/evil"><script id="file-xss">x</script>.txt',
|
||||
)
|
||||
ArchiveResult.objects.filter(pk=result.pk).update(plugin='<script id="plugin-xss">x</script>')
|
||||
|
||||
response = admin_client.get(reverse("admin:core_snapshot_change", args=[snapshot.pk]), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
body = response.content
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'<script id="machine-xss">' not in body
|
||||
assert b'<script id="version-xss">' not in body
|
||||
assert b'<script id="pwd-xss">' not in body
|
||||
assert b'<script id="file-xss">' not in body
|
||||
assert b'<script id="plugin-xss">' not in body
|
||||
assert b"<script id="machine-xss">" in body
|
||||
assert b"<b id="version-xss">v</b>" in body
|
||||
assert b"<script id="pwd-xss">" in body
|
||||
assert b"<script id="file-xss">" in body
|
||||
assert b"<script id="plugin-xss">" in body
|
||||
|
||||
|
||||
def test_snapshot_changelist_bulk_permissions_action_updates_selected_snapshots(client, admin_user, crawl, snapshot):
|
||||
client.force_login(admin_user)
|
||||
url = reverse("admin:core_snapshot_changelist")
|
||||
|
||||
@@ -243,6 +243,37 @@ def test_live_config_value_view_renames_source_field_and_uses_plugin_definition_
|
||||
assert "abx_plugins/plugins/parse_dom_outlinks/config.json" in section["help_texts"]["Type"]
|
||||
|
||||
|
||||
def test_live_config_list_view_escapes_machine_config_values(admin_request, machine):
|
||||
payload = '</code><script id="config-list-xss">window.__archivebox_config_list_xss__=1</script>'
|
||||
machine.config = {"USER_AGENT": payload}
|
||||
machine.save(update_fields=["config"])
|
||||
|
||||
context = core_views.live_config_list_view.__wrapped__(admin_request("/admin/environment/config/"))
|
||||
|
||||
keys = [str(item.link_item) for item in context["table"]["Key"]]
|
||||
value_cell = str(context["table"]["Value"][keys.index("USER_AGENT")])
|
||||
assert '<script id="config-list-xss">' not in value_cell
|
||||
assert "</code><script id="config-list-xss">" in value_cell
|
||||
|
||||
|
||||
def test_live_config_value_view_escapes_machine_config_source_values(admin_request, machine):
|
||||
payload = '</code><script id="config-value-xss">window.__archivebox_config_value_xss__=1</script>'
|
||||
machine.config = {"USER_AGENT": payload}
|
||||
machine.save(update_fields=["config"])
|
||||
|
||||
context = core_views.live_config_value_view.__wrapped__(
|
||||
admin_request("/admin/environment/config/USER_AGENT/"),
|
||||
key="USER_AGENT",
|
||||
)
|
||||
section = context["data"][0]
|
||||
|
||||
assert section["fields"]["Currently read from"] == "Machine"
|
||||
assert section["fields"]["Value"] == payload
|
||||
help_text = str(section["help_texts"]["Value"])
|
||||
assert '<script id="config-value-xss">' not in help_text
|
||||
assert "</code><script id="config-value-xss">" in help_text
|
||||
|
||||
|
||||
def test_find_config_source_prefers_machine_over_environment_and_file(monkeypatch, machine):
|
||||
monkeypatch.setenv("CHECK_SSL_VALIDITY", "false")
|
||||
machine.config = {"CHECK_SSL_VALIDITY": "true"}
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone as dt_timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -15,6 +16,43 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class TestLiveProgressView:
|
||||
def test_live_progress_rejects_unauthenticated_unscoped_request(self, client):
|
||||
response = client.get(reverse("live_progress"), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "Permission denied"}
|
||||
assert b"orchestrator_running" not in response.content
|
||||
assert b"active_crawls" not in response.content
|
||||
assert b"traceback" not in response.content
|
||||
|
||||
def test_admin_live_progress_path_does_not_bypass_admin_auth(self, client):
|
||||
response = client.get("/admin/live-progress/", HTTP_HOST=ADMIN_TEST_HOST)
|
||||
|
||||
assert response.status_code in (302, 403, 404)
|
||||
assert b"orchestrator_running" not in response.content
|
||||
assert b"active_crawls" not in response.content
|
||||
assert b"traceback" not in response.content
|
||||
|
||||
@override_settings(DEBUG=False)
|
||||
def test_live_progress_error_response_hides_traceback_without_debug(self, client, admin_user, crawl):
|
||||
from archivebox.crawls.models import Crawl
|
||||
|
||||
Crawl.objects.filter(pk=crawl.pk).update(
|
||||
status=Crawl.StatusChoices.STARTED,
|
||||
config={"CRAWL_MAX_URLS": "not-an-integer"},
|
||||
retry_at=timezone.now(),
|
||||
modified_at=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(admin_user)
|
||||
response = client.get(reverse("live_progress"), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
|
||||
assert response.status_code == 500
|
||||
payload = response.json()
|
||||
assert "error" in payload
|
||||
assert "traceback" not in payload
|
||||
assert payload["active_crawls"] == []
|
||||
|
||||
def test_live_progress_excludes_old_archiveresults_from_previous_snapshot_run(self, client, admin_user, crawl, snapshot):
|
||||
from datetime import timedelta
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
@@ -280,6 +280,53 @@ class TestPublicIndex:
|
||||
assert b"Unlisted Snapshot" not in response.content
|
||||
assert b"Private Snapshot" not in response.content
|
||||
|
||||
@override_settings(PUBLIC_INDEX=True)
|
||||
def test_public_snapshot_surfaces_escape_legacy_raw_title_and_tag_values(self, client, admin_user):
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, Tag
|
||||
from archivebox.crawls.models import Crawl
|
||||
|
||||
crawl = Crawl.objects.create(urls="https://public-xss.example", created_by=admin_user, config={"PERMISSIONS": "public"})
|
||||
snapshot = Snapshot.objects.create(
|
||||
url="https://public-xss.example",
|
||||
title="Safe title before raw SQL",
|
||||
crawl=crawl,
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
)
|
||||
tag = Tag.objects.create(name="safe-tag-before-raw-sql")
|
||||
snapshot.tags.add(tag)
|
||||
snapshot_archive_path = snapshot.archive_path
|
||||
|
||||
title_payload = "Legacy title 1 < 2 & 3 </script><script id=public-title-xss>window.__archivebox_public_title_xss__=1</script>"
|
||||
tag_payload = "</script><script id=public-tag-xss>window.__archivebox_public_tag_xss__=1</script>"
|
||||
url_payload = "https://public-xss.example/</script><script id=public-url-xss>window.__archivebox_public_url_xss__=1</script>"
|
||||
filename_payload = 'evil"><script id=public-file-xss>window.__archivebox_public_file_xss__=1</script>.txt'
|
||||
Snapshot.objects.filter(pk=snapshot.pk).update(title=title_payload, url=url_payload)
|
||||
Tag.objects.filter(pk=tag.pk).update(name=tag_payload)
|
||||
ArchiveResult.objects.create(
|
||||
snapshot=snapshot,
|
||||
plugin="staticfile",
|
||||
hook_name="on_Snapshot__00_staticfile.py",
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
output_files={filename_payload: {"size": 12, "mimetype": "text/plain"}},
|
||||
)
|
||||
|
||||
public_index = client.get("/public/", HTTP_HOST=WEB_TEST_HOST)
|
||||
snapshot_detail = client.get(f"/{snapshot_archive_path}/index.html", HTTP_HOST=WEB_TEST_HOST)
|
||||
|
||||
assert public_index.status_code == 200
|
||||
assert snapshot_detail.status_code == 200
|
||||
for response in (public_index, snapshot_detail):
|
||||
assert b"<script id=public-title-xss>" not in response.content
|
||||
assert b"<script id=public-tag-xss>" not in response.content
|
||||
assert b"<script id=public-url-xss>" not in response.content
|
||||
assert b"<script id=public-file-xss>" not in response.content
|
||||
assert b"</script><script id=public-tag-xss>" in response.content
|
||||
assert b"</script><script id=public-title-xss>" in public_index.content
|
||||
assert b"window.__archivebox_public_title_xss__=1" in snapshot_detail.content
|
||||
assert b"Legacy title 1 < 2 & 3" in snapshot_detail.content
|
||||
assert b"Legacy title 1 &lt; 2 &amp; 3" not in snapshot_detail.content
|
||||
assert b"<script id=public-file-xss>" in snapshot_detail.content
|
||||
|
||||
def test_direct_snapshot_urls_allow_unlisted_but_not_private_for_guests(self, client, admin_user):
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.crawls.models import Crawl
|
||||
|
||||
@@ -332,6 +332,57 @@ class TestUrlRouting:
|
||||
""",
|
||||
)
|
||||
|
||||
def test_admin_login_next_allows_archivebox_hosts_only(self) -> None:
|
||||
self._run(
|
||||
"""
|
||||
ensure_admin_user()
|
||||
snapshot = get_snapshot()
|
||||
admin_host = get_admin_host()
|
||||
web_host = get_web_host()
|
||||
api_host = get_api_host()
|
||||
snapshot_id = str(snapshot.id)
|
||||
|
||||
allowed_next_urls = [
|
||||
"/admin/core/snapshot/",
|
||||
f"http://archivebox.localhost:8000/public/",
|
||||
f"http://{web_host}/public/",
|
||||
f"http://{admin_host}/admin/core/snapshot/",
|
||||
f"http://{api_host}/api/v1/docs",
|
||||
build_snapshot_url(snapshot_id, "index.html"),
|
||||
build_original_url(snapshot.domain, "index.html"),
|
||||
]
|
||||
for next_url in allowed_next_urls:
|
||||
client = Client()
|
||||
resp = client.post(
|
||||
"/admin/login/",
|
||||
data={"username": "testadmin", "password": "testpassword", "next": next_url},
|
||||
HTTP_HOST=admin_host,
|
||||
)
|
||||
assert resp.status_code in (301, 302), (next_url, resp.status_code, response_body(resp)[:500])
|
||||
assert resp["Location"] == next_url, (next_url, resp["Location"])
|
||||
|
||||
blocked_next_urls = [
|
||||
"https://anything.attacker.com/admin/",
|
||||
"//anything.attacker.com/admin/",
|
||||
"https://archivebox.localhost.attacker.com/public/",
|
||||
"http://web.archivebox.localhost:9999/public/",
|
||||
"javascript:alert(1)",
|
||||
]
|
||||
for next_url in blocked_next_urls:
|
||||
client = Client()
|
||||
resp = client.post(
|
||||
"/admin/login/",
|
||||
data={"username": "testadmin", "password": "testpassword", "next": next_url},
|
||||
HTTP_HOST=admin_host,
|
||||
)
|
||||
assert resp.status_code in (301, 302), (next_url, resp.status_code, response_body(resp)[:500])
|
||||
assert resp["Location"] == "/admin/", (next_url, resp["Location"])
|
||||
assert "attacker.com" not in resp["Location"]
|
||||
|
||||
print("OK")
|
||||
""",
|
||||
)
|
||||
|
||||
def test_snapshot_routing_and_hosts(self) -> None:
|
||||
self._run(
|
||||
"""
|
||||
@@ -743,14 +794,14 @@ class TestUrlRouting:
|
||||
self._set_config("BIND_ADDR=127.0.0.1:8000", "BASE_URL=http://archivebox.localhost:8000")
|
||||
|
||||
def test_subdomain_replay_assets_route_without_base_url(self) -> None:
|
||||
chrome_extensions_dir = self.data_dir / "test-chrome-extensions"
|
||||
lib_dir = self.data_dir / "test-lib"
|
||||
try:
|
||||
self._set_config("BIND_ADDR=127.0.0.1:8766", "BASE_URL=")
|
||||
self._run(
|
||||
"""
|
||||
snapshot = get_snapshot()
|
||||
snapshot_host = get_snapshot_host(str(snapshot.id))
|
||||
extension_dir = Path(SERVER_CONFIG.CHROME_EXTENSIONS_DIR) / "test__archivewebpage"
|
||||
extension_dir = Path(SERVER_CONFIG.ABXPKG_LIB_DIR) / "chromewebstore" / "extensions" / "test__archivewebpage"
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "ui.js").write_text("window.__archivebox_replay_ui__ = true;\\n", encoding="utf-8")
|
||||
(extension_dir / "sw.js").write_text("self.__archivebox_replay_sw__ = true;\\n", encoding="utf-8")
|
||||
@@ -766,7 +817,7 @@ class TestUrlRouting:
|
||||
print("OK")
|
||||
""",
|
||||
mode="safe-subdomains-fullreplay",
|
||||
env_overrides={"CHROME_EXTENSIONS_DIR": str(chrome_extensions_dir)},
|
||||
env_overrides={"ABXPKG_LIB_DIR": str(lib_dir)},
|
||||
)
|
||||
finally:
|
||||
self._set_config("BIND_ADDR=127.0.0.1:8000", "BASE_URL=http://archivebox.localhost:8000")
|
||||
@@ -779,10 +830,9 @@ class TestUrlRouting:
|
||||
"""
|
||||
snapshot = get_snapshot()
|
||||
snapshot_host = get_snapshot_host(str(snapshot.id))
|
||||
expected_extensions_dir = Path(SERVER_CONFIG.LIB_DIR) / "chromewebstore" / "extensions"
|
||||
assert Path(SERVER_CONFIG.CHROME_EXTENSIONS_DIR).resolve() == expected_extensions_dir.resolve()
|
||||
expected_extensions_dir = Path(SERVER_CONFIG.ABXPKG_LIB_DIR) / "chromewebstore" / "extensions"
|
||||
|
||||
extension_dir = Path(SERVER_CONFIG.CHROME_EXTENSIONS_DIR) / "test__archivewebpage"
|
||||
extension_dir = expected_extensions_dir / "test__archivewebpage"
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "ui.js").write_text("window.__archivebox_replay_ui_from_lib__ = true;\\n", encoding="utf-8")
|
||||
(extension_dir / "sw.js").write_text("self.__archivebox_replay_sw_from_lib__ = true;\\n", encoding="utf-8")
|
||||
@@ -798,7 +848,7 @@ class TestUrlRouting:
|
||||
print("OK")
|
||||
""",
|
||||
mode="safe-subdomains-fullreplay",
|
||||
env_overrides={"LIB_DIR": str(lib_dir)},
|
||||
env_overrides={"ABXPKG_LIB_DIR": str(lib_dir)},
|
||||
)
|
||||
finally:
|
||||
self._set_config("BIND_ADDR=127.0.0.1:8000", "BASE_URL=http://archivebox.localhost:8000")
|
||||
@@ -998,6 +1048,27 @@ class TestUrlRouting:
|
||||
assert resp.status_code == 200
|
||||
assert resp["Content-Type"].startswith("text/html")
|
||||
|
||||
root_screenshot = bytes.fromhex(
|
||||
"89504e470d0a1a0a"
|
||||
"0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d49444154789c63606060600000050001a5f64540"
|
||||
"0000000049454e44ae426082"
|
||||
)
|
||||
(Path(snapshot.output_dir) / "screenshot.png").write_bytes(root_screenshot)
|
||||
ArchiveResult.objects.update_or_create(
|
||||
snapshot=snapshot,
|
||||
plugin="screenshot",
|
||||
defaults={
|
||||
"status": ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
"output_files": {"screenshot.png": {"size": len(root_screenshot), "root_relative": True}},
|
||||
"output_str": "screenshot.png",
|
||||
},
|
||||
)
|
||||
resp = client.get("/screenshot/screenshot.png", HTTP_HOST=snapshot_host)
|
||||
assert resp.status_code == 200
|
||||
assert resp["Content-Type"].startswith("image/png")
|
||||
assert response_body(resp) == root_screenshot
|
||||
|
||||
mhtml_dir = Path(snapshot.output_dir) / "chrome_mhtml"
|
||||
mhtml_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mhtml_dir / "snapshot.mhtml").write_text(
|
||||
|
||||
+21
-1
@@ -22,6 +22,23 @@ declare -a TAG_NAMES=("$@")
|
||||
BRANCH_NAME="${1:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
VERSION="$(grep '^version = ' "${REPO_DIR}/pyproject.toml" | awk -F'"' '{print $2}')"
|
||||
GIT_SHA=sha-"$(git rev-parse --short HEAD)"
|
||||
ABX_DL_VERSION="$(python3 - <<'PY'
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
deps = tomllib.load(f)["project"]["dependencies"]
|
||||
|
||||
for dep in deps:
|
||||
match = re.match(r"abx-dl\s*(?:==|>=)\s*([^,;\s]+)", dep)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise SystemExit("Missing abx-dl dependency in pyproject.toml")
|
||||
PY
|
||||
)"
|
||||
ABX_DL_IMAGE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}"
|
||||
NATIVE_ARCH="$(docker info --format '{{.Architecture}}' 2>/dev/null || uname -m)"
|
||||
case "$NATIVE_ARCH" in
|
||||
x86_64|amd64) NATIVE_PLATFORM="linux/amd64" ;;
|
||||
@@ -41,7 +58,7 @@ if ! echo "${TAG_NAMES[@]}" | grep -q "$VERSION"; then
|
||||
TAG_NAMES+=("$VERSION")
|
||||
fi
|
||||
|
||||
echo "[+] Building Docker image for $SELECTED_PLATFORMS: branch=$BRANCH_NAME version=$VERSION tags=${TAG_NAMES[*]}"
|
||||
echo "[+] Building Docker image for $SELECTED_PLATFORMS: branch=$BRANCH_NAME version=$VERSION abx_dl_image=$ABX_DL_IMAGE tags=${TAG_NAMES[*]}"
|
||||
|
||||
declare -a FULL_TAG_NAMES
|
||||
# for each tag in TAG_NAMES, add archivebox/archivebox:tag and its mirrors to FULL_TAG_NAMES
|
||||
@@ -97,6 +114,7 @@ check_platforms || (recreate_builder && check_platforms) || exit 1
|
||||
|
||||
echo "[+] Building archivebox:$VERSION docker image..."
|
||||
mkdir -p "$HOME/.cache/docker/archivebox"
|
||||
docker buildx imagetools inspect "$ABX_DL_IMAGE"
|
||||
# docker builder prune
|
||||
# docker build . --no-cache -t archivebox-dev \
|
||||
# replace --load with --push to deploy
|
||||
@@ -107,6 +125,8 @@ if [[ "$SELECTED_PLATFORMS" == *,* ]]; then
|
||||
fi
|
||||
docker buildx build \
|
||||
--platform "$SELECTED_PLATFORMS" \
|
||||
--pull \
|
||||
--build-arg "ABX_DL_IMAGE=$ABX_DL_IMAGE" \
|
||||
--cache-from type=local,src="$HOME/.cache/docker/archivebox" \
|
||||
--cache-to type=local,compression=zstd,mode=min,oci-mediatypes=true,dest="$HOME/.cache/docker/archivebox" \
|
||||
--load . ${FULL_TAG_NAMES[@]}
|
||||
@@ -11,6 +11,7 @@ DEPLOY_PORT="${DEPLOY_PORT:-44}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/archivebox.demo}"
|
||||
DEPLOY_SERVICE="${DEPLOY_SERVICE:-archivebox}"
|
||||
DEPLOY_IMAGE="${DEPLOY_IMAGE:-archivebox/archivebox:dev}"
|
||||
DEPLOY_EXPECT_VERSION="${DEPLOY_EXPECT_VERSION:-}"
|
||||
|
||||
VERSION="$(grep '^version = ' pyproject.toml | awk -F'"' '{print $2}')"
|
||||
GIT_SHA="sha-$(git rev-parse --short HEAD)"
|
||||
@@ -40,7 +41,7 @@ if [[ "${SKIP_DEMO:-0}" == "1" ]]; then
|
||||
fi
|
||||
|
||||
echo "[+] Deploying ${DEPLOY_IMAGE} on ${DEPLOY_HOST}:${DEPLOY_PATH}..."
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_HOST" DEPLOY_PATH="$DEPLOY_PATH" DEPLOY_SERVICE="$DEPLOY_SERVICE" DEPLOY_IMAGE="$DEPLOY_IMAGE" 'bash -s' <<'REMOTE'
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_HOST" DEPLOY_PATH="$DEPLOY_PATH" DEPLOY_SERVICE="$DEPLOY_SERVICE" DEPLOY_IMAGE="$DEPLOY_IMAGE" DEPLOY_EXPECT_VERSION="$DEPLOY_EXPECT_VERSION" 'bash -s' <<'REMOTE'
|
||||
set -Eeuo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
@@ -81,10 +82,15 @@ if "${COMPOSE[@]}" config --services | grep -qx argo; then
|
||||
fi
|
||||
|
||||
echo "[+] ArchiveBox version:"
|
||||
"${COMPOSE[@]}" exec -T "$DEPLOY_SERVICE" archivebox version | sed -n '1,40p'
|
||||
VERSION_OUTPUT="$("${COMPOSE[@]}" exec -T "$DEPLOY_SERVICE" archivebox version </dev/null 2>&1 || true)"
|
||||
printf '%s\n' "$VERSION_OUTPUT" | sed -n '1,40p'
|
||||
if [[ -n "$DEPLOY_EXPECT_VERSION" ]] && ! grep -q "ArchiveBox v${DEPLOY_EXPECT_VERSION}" <<<"$VERSION_OUTPUT"; then
|
||||
echo "[X] Deployed container is not running ArchiveBox ${DEPLOY_EXPECT_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[+] Health check:"
|
||||
"${COMPOSE[@]}" exec -T "$DEPLOY_SERVICE" curl -fsS --max-time 10 --connect-timeout 2 -H 'Host: admin.archivebox.io' http://127.0.0.1:8000/health/
|
||||
"${COMPOSE[@]}" exec -T "$DEPLOY_SERVICE" curl -fsS --max-time 10 --connect-timeout 2 -H 'Host: admin.archivebox.io' http://127.0.0.1:8000/health/ </dev/null
|
||||
REMOTE
|
||||
|
||||
echo "[√] Demo deploy finished."
|
||||
+37
-60
@@ -3,10 +3,9 @@
|
||||
# This Docker ENTRYPOINT script is called by `docker run archivebox ...` or `docker compose run archivebox ...`.
|
||||
# It takes a CMD as $* shell arguments and runs it following these setup steps:
|
||||
|
||||
# - Set the archivebox user to use the correct PUID & PGID
|
||||
# 1. highest precedence is for valid PUID and PGID env vars passed in explicitly
|
||||
# 2. fall back to DETECTED_PUID of files found within existing data dir
|
||||
# 3. fall back to DEFAULT_PUID if no data dir or its owned by root
|
||||
# - Set the archivebox user to match the existing /data owner when possible
|
||||
# 1. use the first non-root owner detected from existing collection files
|
||||
# 2. fall back to the image's default archivebox uid/gid when /data is root-owned
|
||||
# - Create a new /data dir if necessary and set the correct ownership on it
|
||||
# - Create a new /browsers dir if necessary and set the correct ownership on it
|
||||
# - Check whether we're running inside QEMU emulation and show a warning if so.
|
||||
@@ -31,74 +30,55 @@ ulimit -c 0 >/dev/null 2>&1 || true
|
||||
export DATA_DIR="${DATA_DIR:-/data}"
|
||||
export CONFIG_DIR="${CONFIG_DIR:-/opt/archivebox}"
|
||||
export TMP_DIR="${TMP_DIR:-/tmp/archivebox}"
|
||||
export LIB_DIR="${LIB_DIR:-/opt/archivebox/lib}"
|
||||
export ABXPKG_LIB_DIR="${ABXPKG_LIB_DIR:-$LIB_DIR}"
|
||||
export ABXPKG_LIB_DIR="${ABXPKG_LIB_DIR:-/opt/archivebox/lib}"
|
||||
export ARCHIVEBOX_USER="${ARCHIVEBOX_USER:-archivebox}"
|
||||
export PERSONAS_DIR="${PERSONAS_DIR:-$DATA_DIR/personas}"
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$LIB_DIR/playwright/cache}"
|
||||
export ABXBUS_CACHE_DIR="${ABXBUS_CACHE_DIR:-${XDG_CACHE_HOME:-/home/archivebox/.cache}/abxbus}"
|
||||
export UV_CACHE_DIR="${UV_CACHE_DIR:-${XDG_CACHE_HOME:-/home/archivebox/.cache}/uv}"
|
||||
export PNPM_HOME="${PNPM_HOME:-${XDG_CACHE_HOME:-/home/archivebox/.cache}/pnpm}"
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$ABXPKG_LIB_DIR/playwright/cache}"
|
||||
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$ABXPKG_LIB_DIR/cache}"
|
||||
export ABXBUS_CACHE_DIR="${ABXBUS_CACHE_DIR:-$XDG_CACHE_HOME/abxbus}"
|
||||
export UV_CACHE_DIR="${UV_CACHE_DIR:-$XDG_CACHE_HOME/uv}"
|
||||
|
||||
# Global default PUID and PGID if data dir is empty and no intended PUID+PGID is set manually by user
|
||||
export DEFAULT_PUID=911
|
||||
export DEFAULT_PGID=911
|
||||
# Global default uid/gid used when /data is empty or root-owned.
|
||||
export DEFAULT_ARCHIVEBOX_UID="${DEFAULT_ARCHIVEBOX_UID:-911}"
|
||||
export DEFAULT_ARCHIVEBOX_GID="${DEFAULT_ARCHIVEBOX_GID:-911}"
|
||||
|
||||
detect_data_owner() {
|
||||
local path uid gid
|
||||
for path in "$DATA_DIR/ArchiveBox.conf" "$DATA_DIR/index.sqlite3" "$DATA_DIR/logs" "$DATA_DIR/archive" "$DATA_DIR"; do
|
||||
if [[ -e "$path" ]]; then
|
||||
uid="$(stat -c '%u' "$path" 2>/dev/null || echo "$DEFAULT_PUID")"
|
||||
gid="$(stat -c '%g' "$path" 2>/dev/null || echo "$DEFAULT_PGID")"
|
||||
uid="$(stat -c '%u' "$path" 2>/dev/null || echo "$DEFAULT_ARCHIVEBOX_UID")"
|
||||
gid="$(stat -c '%g' "$path" 2>/dev/null || echo "$DEFAULT_ARCHIVEBOX_GID")"
|
||||
if [[ "$uid" != "0" && "$gid" != "0" ]]; then
|
||||
echo "$uid:$gid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$DEFAULT_PUID:$DEFAULT_PGID"
|
||||
echo "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID"
|
||||
}
|
||||
|
||||
export DETECTED_OWNER="$(detect_data_owner)"
|
||||
export DETECTED_PUID="${DETECTED_OWNER%%:*}"
|
||||
export DETECTED_PGID="${DETECTED_OWNER##*:}"
|
||||
export PUID="${PUID:-$DETECTED_PUID}"
|
||||
export PGID="${PGID:-$DETECTED_PGID}"
|
||||
export REQUESTED_PUID="$PUID"
|
||||
export REQUESTED_PGID="$PGID"
|
||||
|
||||
if [[ ! "$PUID" =~ ^[0-9]+$ || ! "$PGID" =~ ^[0-9]+$ ]]; then
|
||||
echo -e "\n[X] Error: PUID and PGID must be numeric, got PUID=$PUID PGID=$PGID." > /dev/stderr
|
||||
echo -e " Example: PUID=$(id -u) PGID=$(id -g) docker compose up" > /dev/stderr
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# If user tries to set PUID or PGID to root values, keep root only for entrypoint
|
||||
# setup and run ArchiveBox/Chrome as the default non-root user instead.
|
||||
if [[ "$PUID" == "0" || "$PGID" == "0" ]]; then
|
||||
[[ "$PUID" == "0" ]] && export PUID="$DEFAULT_PUID"
|
||||
[[ "$PGID" == "0" ]] && export PGID="$DEFAULT_PGID"
|
||||
echo -e "\n[!] Warning: Got PUID=$REQUESTED_PUID PGID=$REQUESTED_PGID, but ArchiveBox/Chrome should not run as root." > /dev/stderr
|
||||
echo -e " The entrypoint will use root only for startup permission repair, then run ArchiveBox as a non-root user." > /dev/stderr
|
||||
echo -e " Using PUID=$PUID PGID=$PGID. See https://docs.linuxserver.io/general/understanding-puid-and-pgid" > /dev/stderr
|
||||
fi
|
||||
export TARGET_UID="${DETECTED_OWNER%%:*}"
|
||||
export TARGET_GID="${DETECTED_OWNER##*:}"
|
||||
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
# Set archivebox user and group ids to desired PUID/PGID.
|
||||
groupmod -o -g "$PGID" "$ARCHIVEBOX_USER" > /dev/null 2>&1 || {
|
||||
echo -e "\n[X] Error: Failed to set $ARCHIVEBOX_USER group to PGID=$PGID." > /dev/stderr
|
||||
# Root is only used for startup permission repair. ArchiveBox/Chrome run as
|
||||
# the existing collection owner, or as the image default user for root-owned
|
||||
# /data, so application code never needs to know about Docker uid mapping.
|
||||
groupmod -o -g "$TARGET_GID" "$ARCHIVEBOX_USER" > /dev/null 2>&1 || {
|
||||
echo -e "\n[X] Error: Failed to set $ARCHIVEBOX_USER group to gid=$TARGET_GID." > /dev/stderr
|
||||
exit 3
|
||||
}
|
||||
usermod -o -u "$PUID" -g "$PGID" "$ARCHIVEBOX_USER" > /dev/null 2>&1 || {
|
||||
echo -e "\n[X] Error: Failed to set $ARCHIVEBOX_USER user to PUID=$PUID PGID=$PGID." > /dev/stderr
|
||||
usermod -o -u "$TARGET_UID" -g "$TARGET_GID" "$ARCHIVEBOX_USER" > /dev/null 2>&1 || {
|
||||
echo -e "\n[X] Error: Failed to set $ARCHIVEBOX_USER user to uid=$TARGET_UID gid=$TARGET_GID." > /dev/stderr
|
||||
exit 3
|
||||
}
|
||||
|
||||
export PUID="$(id -u "$ARCHIVEBOX_USER")"
|
||||
export PGID="$(id -g "$ARCHIVEBOX_USER")"
|
||||
export TARGET_UID="$(id -u "$ARCHIVEBOX_USER")"
|
||||
export TARGET_GID="$(id -g "$ARCHIVEBOX_USER")"
|
||||
else
|
||||
export PUID="$(id -u)"
|
||||
export PGID="$(id -g)"
|
||||
export TARGET_UID="$(id -u)"
|
||||
export TARGET_GID="$(id -g)"
|
||||
fi
|
||||
|
||||
# Check if user attempted to run it in the root of their home folder or hard drive (common mistake)
|
||||
@@ -112,8 +92,8 @@ chown_if_needed() {
|
||||
local path="$1"
|
||||
[[ -e "$path" ]] || return 0
|
||||
[[ "$(id -u)" == "0" ]] || return 0
|
||||
[[ "$(stat -c '%u:%g' "$path" 2>/dev/null || true)" == "$PUID:$PGID" ]] && return 0
|
||||
chown -h "$PUID:$PGID" "$path" 2>/dev/null || true
|
||||
[[ "$(stat -c '%u:%g' "$path" 2>/dev/null || true)" == "$TARGET_UID:$TARGET_GID" ]] && return 0
|
||||
chown -h "$TARGET_UID:$TARGET_GID" "$path" 2>/dev/null || true
|
||||
}
|
||||
|
||||
chmod_if_possible() {
|
||||
@@ -140,8 +120,8 @@ ensure_runtime_tree() {
|
||||
local path="$1"
|
||||
mkdir -p "$path" 2>/dev/null || true
|
||||
[[ -e "$path" ]] || return 0
|
||||
if [[ "$(id -u)" == "0" ]] && [[ "$(stat -c '%u:%g' "$path" 2>/dev/null || true)" != "$PUID:$PGID" ]]; then
|
||||
chown -R "$PUID:$PGID" "$path" 2>/dev/null || true
|
||||
if [[ "$(id -u)" == "0" ]] && [[ "$(stat -c '%u:%g' "$path" 2>/dev/null || true)" != "$TARGET_UID:$TARGET_GID" ]]; then
|
||||
chown -R "$TARGET_UID:$TARGET_GID" "$path" 2>/dev/null || true
|
||||
fi
|
||||
chmod_if_possible "$path"
|
||||
}
|
||||
@@ -150,7 +130,7 @@ ensure_runtime_tmp_tree() {
|
||||
mkdir -p "$TMP_DIR" 2>/dev/null || true
|
||||
[[ -e "$TMP_DIR" ]] || return 0
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
chown -R "$PUID:$PGID" "$TMP_DIR" 2>/dev/null || true
|
||||
chown -R "$TARGET_UID:$TARGET_GID" "$TMP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
chmod_if_possible "$TMP_DIR"
|
||||
}
|
||||
@@ -160,7 +140,7 @@ ensure_small_runtime_tree() {
|
||||
mkdir -p "$path" 2>/dev/null || true
|
||||
[[ -e "$path" ]] || return 0
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
chown -R "$PUID:$PGID" "$path" 2>/dev/null || true
|
||||
chown -R "$TARGET_UID:$TARGET_GID" "$path" 2>/dev/null || true
|
||||
fi
|
||||
chmod -R u+rwX,g+rwX "$path" 2>/dev/null || true
|
||||
}
|
||||
@@ -175,12 +155,10 @@ run_as_archivebox() {
|
||||
|
||||
permission_error() {
|
||||
local path="$1"
|
||||
echo -e "\n[X] Error: archivebox user (PUID=$PUID PGID=$PGID) cannot write to $path." > /dev/stderr
|
||||
echo -e "\n[X] Error: archivebox user (uid=$TARGET_UID gid=$TARGET_GID) cannot write to $path." > /dev/stderr
|
||||
echo -e " Current owner is $(stat -c '%u:%g' "$path" 2>/dev/null || echo 'unknown')." > /dev/stderr
|
||||
echo -e " Fix ownership on the host, or set PUID/PGID to match the mount owner:" > /dev/stderr
|
||||
echo -e " PUID=$PUID PGID=$PGID docker compose up" > /dev/stderr
|
||||
echo -e " chown -R $PUID:$PGID ./data # only if you intentionally want to repair the full tree" > /dev/stderr
|
||||
echo -e " https://docs.linuxserver.io/general/understanding-puid-and-pgid" > /dev/stderr
|
||||
echo -e " Fix ownership on the host so /data is writable by the intended archivebox user:" > /dev/stderr
|
||||
echo -e " chown -R $TARGET_UID:$TARGET_GID ./data # only if you intentionally want to repair the full tree" > /dev/stderr
|
||||
exit 3
|
||||
}
|
||||
|
||||
@@ -232,10 +210,9 @@ ensure_dir "/home/$ARCHIVEBOX_USER"
|
||||
ensure_small_runtime_tree "$ABXBUS_CACHE_DIR"
|
||||
ensure_small_runtime_tree "$ABXBUS_CACHE_DIR/semaphores"
|
||||
ensure_small_runtime_tree "$UV_CACHE_DIR"
|
||||
ensure_small_runtime_tree "$PNPM_HOME"
|
||||
ensure_runtime_tree "$PLAYWRIGHT_BROWSERS_PATH"
|
||||
ensure_runtime_tmp_tree
|
||||
ensure_runtime_tree "$LIB_DIR"
|
||||
ensure_runtime_tree "$ABXPKG_LIB_DIR"
|
||||
run_as_archivebox touch "$ABXBUS_CACHE_DIR/semaphores/.permissions_test_safe_to_delete" 2>/dev/null || permission_error "$ABXBUS_CACHE_DIR/semaphores"
|
||||
rm -f "$ABXBUS_CACHE_DIR/semaphores/.permissions_test_safe_to_delete"
|
||||
run_as_archivebox touch "$UV_CACHE_DIR/.permissions_test_safe_to_delete" 2>/dev/null || permission_error "$UV_CACHE_DIR"
|
||||
|
||||
+26
-16
@@ -221,6 +221,26 @@ else:
|
||||
PY
|
||||
}
|
||||
|
||||
latest_pypi_version() {
|
||||
local package_name="$1"
|
||||
local releases_json
|
||||
releases_json="$(curl -fsSL "https://pypi.org/pypi/${package_name}/json" | jq -r '.releases | keys[]' || true)"
|
||||
RELEASE_TAGS="${releases_json}" python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
def parse(version: str) -> tuple[int, int, int, int, int]:
|
||||
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-?rc(\d*))?$', version)
|
||||
if not match:
|
||||
return (-1, -1, -1, -1, -1)
|
||||
major, minor, patch, rc = match.groups()
|
||||
return (int(major), int(minor), int(patch), 0 if 'rc' in version else 1, int(rc or '0'))
|
||||
|
||||
versions = [line.strip() for line in os.environ.get('RELEASE_TAGS', '').splitlines() if line.strip()]
|
||||
print(max(versions, key=parse) if versions else '')
|
||||
PY
|
||||
}
|
||||
|
||||
github_release_enabled() {
|
||||
local version="$1"
|
||||
if [[ "${version}" == *rc* && "${CREATE_GITHUB_RC_RELEASES:-0}" != "1" ]]; then
|
||||
@@ -384,7 +404,6 @@ create_release() {
|
||||
|
||||
publish_artifacts() {
|
||||
local version="$1"
|
||||
local pypi_token="${UV_PUBLISH_TOKEN:-${PYPI_TOKEN:-${PYPI_PAT_SECRET:-}}}"
|
||||
local artifact_prefix="${PYPI_PACKAGE//-/_}"
|
||||
local artifacts=()
|
||||
local dist_dir
|
||||
@@ -406,28 +425,19 @@ publish_artifacts() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "${pypi_token}" ]]; then
|
||||
UV_PUBLISH_TOKEN="${pypi_token}" uv publish --username=__token__ "${artifacts[@]}"
|
||||
else
|
||||
uv publish --username=__token__ "${artifacts[@]}"
|
||||
fi
|
||||
uv publish --trusted-publishing always "${artifacts[@]}"
|
||||
fi
|
||||
|
||||
wait_for_pypi "${PYPI_PACKAGE}" "${version}"
|
||||
}
|
||||
|
||||
main() {
|
||||
local slug branch version latest relation
|
||||
local slug branch version latest pypi_latest relation
|
||||
|
||||
source_optional_env
|
||||
slug="$(repo_slug)"
|
||||
branch="$(default_branch)"
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == "push" ]]; then
|
||||
validate_release_state "${slug}" "${branch}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$(git branch --show-current)" != "${branch}" ]]; then
|
||||
echo "Release must run from ${branch}, found $(git branch --show-current)" >&2
|
||||
return 1
|
||||
@@ -435,6 +445,10 @@ main() {
|
||||
|
||||
version="$(current_version)"
|
||||
latest="$(latest_release_version "${slug}")"
|
||||
pypi_latest="$(latest_pypi_version "${PYPI_PACKAGE}")"
|
||||
if [[ -n "${pypi_latest}" && ( -z "${latest}" || "$(compare_versions "${pypi_latest}" "${latest}")" == "gt" ) ]]; then
|
||||
latest="${pypi_latest}"
|
||||
fi
|
||||
if [[ -z "${latest}" ]]; then
|
||||
relation="gt"
|
||||
else
|
||||
@@ -449,16 +463,12 @@ main() {
|
||||
git add -A
|
||||
git commit -m "release: ${TAG_PREFIX}${version}"
|
||||
git push origin "${branch}"
|
||||
|
||||
wait_for_runs "${slug}" push "$(git rev-parse HEAD)" "push"
|
||||
elif [[ "${relation}" == "gt" ]]; then
|
||||
run_checks
|
||||
if [[ -n "$(git status --short)" ]]; then
|
||||
git add -A
|
||||
git commit -m "release: ${TAG_PREFIX}${version}"
|
||||
git push origin "${branch}"
|
||||
|
||||
wait_for_runs "${slug}" push "$(git rev-parse HEAD)" "push"
|
||||
fi
|
||||
else
|
||||
echo "Current version ${version} is behind latest GitHub release ${latest}" >&2
|
||||
|
||||
+45
-35
@@ -5,7 +5,8 @@ IFS=$'\n\t'
|
||||
|
||||
ARCHIVEBOX_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORKSPACE_DIR="$(cd "${ARCHIVEBOX_REPO}/.." && pwd)"
|
||||
PYPI_USERNAME="${PYPI_USERNAME:-__token__}"
|
||||
PYPI_WAIT_ATTEMPTS="${PYPI_WAIT_ATTEMPTS:-90}"
|
||||
DOCKER_IMAGE_REPOS="${DOCKER_IMAGE_REPOS:-archivebox/archivebox ghcr.io/archivebox/archivebox}"
|
||||
|
||||
cd "${WORKSPACE_DIR}"
|
||||
|
||||
@@ -120,7 +121,7 @@ build_and_prek() {
|
||||
local repo="$1"
|
||||
(
|
||||
cd "$repo"
|
||||
rm -rf dist
|
||||
rm -rf dist .pdm-build
|
||||
uv --no-cache build --out-dir dist
|
||||
# prek auto-fixes (ruff-format, add-trailing-comma, end-of-file-fixer,
|
||||
# …) exit with status 1 whenever they modify files. Some hooks expose
|
||||
@@ -137,7 +138,7 @@ build_and_prek() {
|
||||
fi
|
||||
echo "[*] prek auto-fixed files in $(basename "$repo"); re-running to verify clean…"
|
||||
done
|
||||
rm -rf dist
|
||||
rm -rf dist .pdm-build
|
||||
uv --no-cache build --out-dir dist
|
||||
)
|
||||
}
|
||||
@@ -170,55 +171,63 @@ commit_push_publish() {
|
||||
git tag -a "${tag}" -m "release: ${package} ${version}"
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
uv --no-cache publish --username="${PYPI_USERNAME}" dist/*
|
||||
if pypi_has_release "$package" "$version"; then
|
||||
echo "[*] ${package}==${version} is already on PyPI; skipping upload."
|
||||
else
|
||||
uv --no-cache publish --trusted-publishing always dist/*
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
pypi_has_release() {
|
||||
local package="$1"
|
||||
local version="$2"
|
||||
|
||||
uv run python - "$package" "$version" <<'PY'
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
package, version = sys.argv[1:]
|
||||
try:
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=10):
|
||||
raise SystemExit(0)
|
||||
except urllib.error.HTTPError as err:
|
||||
raise SystemExit(1 if err.code == 404 else 2)
|
||||
PY
|
||||
}
|
||||
|
||||
wait_for_pypi() {
|
||||
local package="$1"
|
||||
local version="$2"
|
||||
local attempts=0
|
||||
|
||||
until uv run python - "$package" "$version" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
package, version = sys.argv[1:]
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/json", timeout=10) as response:
|
||||
data = json.load(response)
|
||||
raise SystemExit(0 if version in data.get("releases", {}) else 1)
|
||||
PY
|
||||
until pypi_has_release "$package" "$version"
|
||||
do
|
||||
attempts=$((attempts + 1))
|
||||
if [[ "$attempts" -ge 30 ]]; then
|
||||
if [[ "$attempts" -ge "$PYPI_WAIT_ATTEMPTS" ]]; then
|
||||
echo "[X] Timed out waiting for ${package}==${version} on PyPI" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
local tmpdir
|
||||
tmpdir="$(mktemp -d)"
|
||||
uv --no-cache venv "${tmpdir}/venv" --python 3.13 >/dev/null
|
||||
attempts=0
|
||||
while true; do
|
||||
set +e
|
||||
uv --no-cache pip install --dry-run --no-deps --python "${tmpdir}/venv/bin/python" "${package}==${version}" >/dev/null
|
||||
status="$?"
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
wait_for_docker_image() {
|
||||
local image="$1"
|
||||
local attempts=0
|
||||
local max_attempts="${DOCKER_IMAGE_WAIT_ATTEMPTS:-60}"
|
||||
|
||||
until docker buildx imagetools inspect "$image" >/dev/null
|
||||
do
|
||||
attempts=$((attempts + 1))
|
||||
if [[ "$attempts" -ge 30 ]]; then
|
||||
rm -rf "$tmpdir"
|
||||
echo "[X] Timed out waiting for uv to resolve ${package}==${version} from PyPI" >&2
|
||||
if [[ "$attempts" -ge "$max_attempts" ]]; then
|
||||
echo "[X] Timed out waiting for ${image}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
echo "[*] ${image} is not published yet; waiting..."
|
||||
sleep 30
|
||||
done
|
||||
rm -rf "$tmpdir"
|
||||
}
|
||||
|
||||
release_python_repo() {
|
||||
@@ -236,8 +245,8 @@ release_python_repo() {
|
||||
wait_for_pypi "$package" "$version"
|
||||
}
|
||||
|
||||
ABXPKG_VERSION="$(next_patch_version "$(current_version "$(repo_dir abxpkg)")")"
|
||||
ABX_SHARED_VERSION="$(next_patch_version "$(current_version "$(repo_dir abx-plugins)")" "$(current_version "$(repo_dir abx-dl)")")"
|
||||
ABXPKG_VERSION="${ABXPKG_VERSION:-$(next_patch_version "$(current_version "$(repo_dir abxpkg)")")}"
|
||||
ABX_SHARED_VERSION="${ABX_SHARED_VERSION:-$(next_patch_version "$(current_version "$(repo_dir abx-plugins)")" "$(current_version "$(repo_dir abx-dl)")")}"
|
||||
|
||||
bump_patch_to "$(repo_dir abxpkg)" "$ABXPKG_VERSION"
|
||||
release_python_repo abxpkg main abxpkg "$ABXPKG_VERSION"
|
||||
@@ -263,8 +272,9 @@ commit_push_publish "$ARCHIVEBOX_REPO" dev archivebox "$ARCHIVEBOX_VERSION"
|
||||
|
||||
(
|
||||
cd "$ARCHIVEBOX_REPO"
|
||||
wait_for_docker_image "archivebox/abx-dl:${ABX_SHARED_VERSION}"
|
||||
./bin/release_docker.sh dev "$ARCHIVEBOX_VERSION" "sha-$(git rev-parse --short HEAD)"
|
||||
SKIP_DOCKER=1 ./bin/deploy_dev_demo.sh
|
||||
DEPLOY_IMAGE="${DOCKER_IMAGE_REPOS%% *}:dev" DEPLOY_EXPECT_VERSION="$ARCHIVEBOX_VERSION" SKIP_DOCKER=1 ./bin/deploy_dev_demo.sh
|
||||
)
|
||||
|
||||
echo "[√] Released abxpkg ${ABXPKG_VERSION}, abx-plugins/abx-dl ${ABX_SHARED_VERSION}, archivebox ${ARCHIVEBOX_VERSION}"
|
||||
+34
-11
@@ -18,6 +18,24 @@ BRANCH_NAME="${1:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
VERSION="$(grep '^version = ' "${REPO_DIR}/pyproject.toml" | awk -F'"' '{print $2}')"
|
||||
GIT_SHA=sha-"$(git rev-parse --short HEAD)"
|
||||
SELECTED_PLATFORMS="${DOCKER_PLATFORMS:-${SELECTED_PLATFORMS:-linux/amd64,linux/arm64}}"
|
||||
DOCKER_IMAGE_REPOS="${DOCKER_IMAGE_REPOS:-archivebox/archivebox ghcr.io/archivebox/archivebox}"
|
||||
ABX_DL_VERSION="$(python3 - <<'PY'
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
deps = tomllib.load(f)["project"]["dependencies"]
|
||||
|
||||
for dep in deps:
|
||||
match = re.match(r"abx-dl\s*(?:==|>=)\s*([^,;\s]+)", dep)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise SystemExit("Missing abx-dl dependency in pyproject.toml")
|
||||
PY
|
||||
)"
|
||||
ABX_DL_IMAGE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}"
|
||||
|
||||
# if not already in TAG_NAMES, add GIT_SHA and BRANCH_NAME
|
||||
if ! echo "${TAG_NAMES[@]}" | grep -q "$GIT_SHA"; then
|
||||
@@ -30,14 +48,14 @@ if ! echo "${TAG_NAMES[@]}" | grep -q "$VERSION"; then
|
||||
TAG_NAMES+=("$VERSION")
|
||||
fi
|
||||
|
||||
echo "[+] Building + releasing Docker image for $SELECTED_PLATFORMS: branch=$BRANCH_NAME version=$VERSION tags=${TAG_NAMES[*]}"
|
||||
echo "[+] Building + releasing Docker image for $SELECTED_PLATFORMS: branch=$BRANCH_NAME version=$VERSION abx_dl_image=$ABX_DL_IMAGE tags=${TAG_NAMES[*]}"
|
||||
|
||||
declare -a FULL_TAG_NAMES
|
||||
# for each tag in TAG_NAMES, add archivebox/archivebox:tag and its mirrors to FULL_TAG_NAMES
|
||||
for TAG_NAME in "${TAG_NAMES[@]}"; do
|
||||
[[ "$TAG_NAME" == "" ]] && continue
|
||||
FULL_TAG_NAMES+=("-t archivebox/archivebox:$TAG_NAME") # ArchiveBox official Docker repo
|
||||
FULL_TAG_NAMES+=("-t ghcr.io/archivebox/archivebox:$TAG_NAME") # Github Container Repo mirror
|
||||
for IMAGE_REPO in $DOCKER_IMAGE_REPOS; do
|
||||
FULL_TAG_NAMES+=("-t $IMAGE_REPO:$TAG_NAME")
|
||||
done
|
||||
done
|
||||
echo "${FULL_TAG_NAMES[@]}"
|
||||
|
||||
@@ -84,11 +102,14 @@ check_platforms || (recreate_builder && check_platforms) || exit 1
|
||||
|
||||
echo "[^] Uploading docker image"
|
||||
mkdir -p "$HOME/.cache/docker/archivebox"
|
||||
docker buildx imagetools inspect "$ABX_DL_IMAGE"
|
||||
|
||||
# https://docs.docker.com/build/cache/backends/
|
||||
# shellcheck disable=SC2068
|
||||
docker buildx build \
|
||||
--platform "$SELECTED_PLATFORMS" \
|
||||
--pull \
|
||||
--build-arg "ABX_DL_IMAGE=$ABX_DL_IMAGE" \
|
||||
--cache-from type=local,src="$HOME/.cache/docker/archivebox" \
|
||||
--cache-to type=local,compression=zstd,mode=min,oci-mediatypes=true,dest="$HOME/.cache/docker/archivebox" \
|
||||
--push . ${FULL_TAG_NAMES[@]}
|
||||
@@ -96,13 +117,15 @@ docker buildx build \
|
||||
echo "[^] Verifying pushed Docker manifests include: $SELECTED_PLATFORMS"
|
||||
for TAG_NAME in "${TAG_NAMES[@]}"; do
|
||||
[[ "$TAG_NAME" == "" ]] && continue
|
||||
MANIFEST="$(docker buildx imagetools inspect "archivebox/archivebox:$TAG_NAME")"
|
||||
for REQUIRED_PLATFORM in ${SELECTED_PLATFORMS//,/$IFS}; do
|
||||
if ! echo "$MANIFEST" | grep -q "Platform: $REQUIRED_PLATFORM"; then
|
||||
echo "[X] archivebox/archivebox:$TAG_NAME is missing platform: $REQUIRED_PLATFORM" >&2
|
||||
echo "$MANIFEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
for IMAGE_REPO in $DOCKER_IMAGE_REPOS; do
|
||||
MANIFEST="$(docker buildx imagetools inspect "$IMAGE_REPO:$TAG_NAME")"
|
||||
for REQUIRED_PLATFORM in ${SELECTED_PLATFORMS//,/$IFS}; do
|
||||
if ! echo "$MANIFEST" | grep -q "Platform: $REQUIRED_PLATFORM"; then
|
||||
echo "[X] $IMAGE_REPO:$TAG_NAME is missing platform: $REQUIRED_PLATFORM" >&2
|
||||
echo "$MANIFEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
echo "[√] Docker manifests include all requested platforms."
|
||||
+1
-1
@@ -17,4 +17,4 @@ source "$REPO_DIR/.venv/bin/activate"
|
||||
echo "[^] Publishing to PyPI..."
|
||||
rm -Rf dist
|
||||
uv build
|
||||
uv publish
|
||||
uv publish --trusted-publishing always
|
||||
@@ -87,7 +87,7 @@ docker_setup() {
|
||||
$setup_script"
|
||||
}
|
||||
|
||||
default_cmd='printf "ABX_UID=%s\nABX_GID=%s\nABX_USER=%s\nABX_GROUPS=%s\n" "$(id -u)" "$(id -g)" "$(whoami 2>/dev/null || true)" "$(id -Gn)"; touch /data/logs/probe /data/archive/probe "$LIB_DIR/probe" "$PERSONAS_DIR/Default/chrome_profile/probe"; stat -c "ABX_STAT %u:%g:%a %n" /data /data/logs /data/archive "$LIB_DIR" "$PERSONAS_DIR" "$PERSONAS_DIR/Default" "$PERSONAS_DIR/Default/chrome_profile"; echo ABX_PERSONA_PROFILE_OK; echo ABX_OK'
|
||||
default_cmd='printf "ABX_UID=%s\nABX_GID=%s\nABX_USER=%s\nABX_GROUPS=%s\n" "$(id -u)" "$(id -g)" "$(whoami 2>/dev/null || true)" "$(id -Gn)"; touch /data/logs/probe /data/archive/probe "$ABXPKG_LIB_DIR/probe" "$PERSONAS_DIR/Default/chrome_profile/probe"; stat -c "ABX_STAT %u:%g:%a %n" /data /data/logs /data/archive "$ABXPKG_LIB_DIR" "$PERSONAS_DIR" "$PERSONAS_DIR/Default" "$PERSONAS_DIR/Default/chrome_profile"; echo ABX_PERSONA_PROFILE_OK; echo ABX_OK'
|
||||
version_cmd='printf "ABX_UID=%s\nABX_GID=%s\nABX_USER=%s\nABX_GROUPS=%s\n" "$(id -u)" "$(id -g)" "$(whoami 2>/dev/null || true)" "$(id -Gn)"; archivebox version >/tmp/archivebox-version.out; tail -n 12 /tmp/archivebox-version.out; echo ABX_OK'
|
||||
full_flow_cmd='set -Eeuo pipefail
|
||||
printf "ABX_UID=%s\nABX_GID=%s\nABX_USER=%s\nABX_GROUPS=%s\n" "$(id -u)" "$(id -g)" "$(whoami 2>/dev/null || true)" "$(id -Gn)"
|
||||
@@ -103,9 +103,9 @@ if grep -E "(/[[:alnum:]_.-]+/)?pip install|npm install|uv pip install" /tmp/arc
|
||||
exit 1
|
||||
fi
|
||||
archivebox version 2>&1 | tee /tmp/archivebox-version.log
|
||||
grep -Eq "/home/archivebox/.config/abx/lib/(pip/venv/bin|env/bin)/trafilatura" /tmp/archivebox-version.log
|
||||
grep -Eq "/home/archivebox/.config/abx/lib/(npm/node_modules/.bin|env/bin)/defuddle" /tmp/archivebox-version.log
|
||||
grep -Eq "/home/archivebox/.config/abx/lib/env/bin/sonic" /tmp/archivebox-version.log
|
||||
grep -Eq "/opt/archivebox/lib/(uv/venv/bin|env/bin)/trafilatura" /tmp/archivebox-version.log
|
||||
grep -Eq "/opt/archivebox/lib/(pnpm/packages/defuddle/node_modules/.bin|env/bin)/defuddle" /tmp/archivebox-version.log
|
||||
grep -Eq "/opt/archivebox/lib/env/bin/sonic" /tmp/archivebox-version.log
|
||||
archivebox add --depth=0 https://example.com/ 2>&1 | tee /tmp/archivebox-add.log
|
||||
archivebox update --index-only 2>&1 | tee /tmp/archivebox-update.log
|
||||
snapshot_dir="$(find /data/archive/users/system/snapshots -mindepth 3 -maxdepth 3 -type d | head -n 1)"
|
||||
@@ -163,7 +163,6 @@ run_case() {
|
||||
fi
|
||||
run_args+=(
|
||||
-e DATA_DIR=/data
|
||||
-e LIB_DIR=/libdir
|
||||
-e ABXPKG_LIB_DIR=/libdir
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/browsers
|
||||
)
|
||||
@@ -260,7 +259,6 @@ run_readonly_case() {
|
||||
fi
|
||||
run_args+=(
|
||||
-e DATA_DIR=/data
|
||||
-e LIB_DIR=/libdir
|
||||
-e ABXPKG_LIB_DIR=/libdir
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/browsers
|
||||
)
|
||||
@@ -385,7 +383,7 @@ run_mount_case() {
|
||||
case_root="$mount_dir/archivebox-uidgid-validation-$RUN_ID"
|
||||
mkdir -p "$case_root"
|
||||
VALIDATION_ROOT="$case_root"
|
||||
run_case "$fs_name writable forced-owner style mount" ":" "PUID=911 PGID=911" "-" pass 911 911 "$default_cmd" ""
|
||||
run_case "$fs_name writable forced-owner style mount" ":" "-" "-" pass 911 911 "$default_cmd" ""
|
||||
VALIDATION_ROOT="$previous_root"
|
||||
}
|
||||
|
||||
@@ -395,10 +393,6 @@ run_case "root-owned empty data auto-detect default" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data" \
|
||||
"-" "-" pass 911 911
|
||||
|
||||
run_case "root-owned empty data explicit 1001:1001" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data" \
|
||||
"PUID=1001 PGID=1001" "-" pass 1001 1001
|
||||
|
||||
run_case "501-owned data auto-detected" \
|
||||
"chown 501:20 /case/data && chmod 755 /case/data" \
|
||||
"-" "-" pass 501 20
|
||||
@@ -407,93 +401,41 @@ run_case "non-root data with root-owned config files repaired" \
|
||||
"chown 501:20 /case/data && chmod 755 /case/data && touch /case/data/index.sqlite3 /case/data/ArchiveBox.conf && mkdir -p /case/data/archive/users && chown 0:0 /case/data/index.sqlite3 /case/data/ArchiveBox.conf /case/data/archive/users" \
|
||||
"-" "-" pass 501 20 "$default_cmd" config-files-repaired
|
||||
|
||||
run_case "911-owned data with PGID=0 normalized" \
|
||||
run_case "911-owned data auto-detected" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data" \
|
||||
"PGID=0" "-" pass 911 911
|
||||
"-" "-" pass 911 911
|
||||
|
||||
run_case "911-owned data explicit 911:911" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data" \
|
||||
"PUID=911 PGID=911" "-" pass 911 911
|
||||
|
||||
run_case "PUID=911 PGID=0 with 501-owned writable data" \
|
||||
"chown 501:20 /case/data && chmod 777 /case/data" \
|
||||
"PUID=911 PGID=0" "-" pass 911 911
|
||||
|
||||
run_case "PUID=0 PGID=500 with 1001-owned writable data" \
|
||||
"chown 1001:1001 /case/data && chmod 777 /case/data" \
|
||||
"PUID=0 PGID=500" "-" pass 911 500
|
||||
|
||||
run_case "PUID=0 PGID=0 root-owned data normalized" \
|
||||
run_case "root-owned data falls back to default archivebox user" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data" \
|
||||
"PUID=0 PGID=0" "-" pass 911 911
|
||||
|
||||
run_case "PUID=501 PGID=0 root-owned data normalized group" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data" \
|
||||
"PUID=501 PGID=0" "-" pass 501 911
|
||||
|
||||
run_case "PUID=0 PGID=500 root-owned data normalized user" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data" \
|
||||
"PUID=0 PGID=500" "-" pass 911 500
|
||||
"-" "-" pass 911 911
|
||||
|
||||
run_case "nested root-owned archive content is not recursively chowned" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data && mkdir -p /case/data/archive/existing && touch /case/data/archive/existing/file && chown -R 0:0 /case/data/archive/existing" \
|
||||
"PUID=1001 PGID=1002" "-" pass 1001 1002 "$default_cmd" nested-root-stays
|
||||
|
||||
run_case "non-root start writable root-owned data succeeds" \
|
||||
"chown 0:0 /case/data /case/lib && chmod 777 /case/data /case/lib" \
|
||||
"-" "501:911" pass 501 911
|
||||
|
||||
run_case "non-root start root-owned data with mapped write access succeeds" \
|
||||
"chown 0:0 /case/data /case/lib && chmod 700 /case/data /case/lib" \
|
||||
"-" "501:911" pass 501 911
|
||||
"-" "-" pass 911 911 "$default_cmd" nested-root-stays
|
||||
|
||||
run_case "root start fixes read-only top-level data when chmod works" \
|
||||
"chown 0:0 /case/data && chmod 555 /case/data" \
|
||||
"PUID=911 PGID=911" "-" pass 911 911
|
||||
"-" "-" pass 911 911
|
||||
|
||||
run_case "legacy data users dir repaired when present" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data && mkdir -p /case/data/users && chown 0:0 /case/data/users" \
|
||||
"PUID=911 PGID=911" "-" pass 911 911 "$default_cmd" users-dir-repaired
|
||||
"-" "-" pass 911 911 "$default_cmd" users-dir-repaired
|
||||
|
||||
run_case "archive dir root-owned inside 911 data repaired shallowly" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data && mkdir -p /case/data/archive && chown 0:0 /case/data/archive" \
|
||||
"PUID=911 PGID=911" "-" pass 911 911
|
||||
"-" "-" pass 911 911
|
||||
|
||||
run_case "logs dir root-owned mode 000 repaired shallowly" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data && mkdir -p /case/data/logs && chown 0:0 /case/data/logs && chmod 000 /case/data/logs" \
|
||||
"PUID=911 PGID=911" "-" pass 911 911
|
||||
|
||||
run_case "non-root user 501 can run archivebox version with root-owned LIB_DIR" \
|
||||
"chown 0:0 /case/data /case/lib && chmod 777 /case/data && chmod 755 /case/lib" \
|
||||
"-" "501:911" pass 501 911 "$version_cmd"
|
||||
|
||||
run_case "non-root user 501 can write root-owned mapped LIB_DIR when access is granted" \
|
||||
"chown 0:0 /case/data /case/lib && chmod 777 /case/data && chmod 755 /case/lib" \
|
||||
"-" "501:911" pass 501 911
|
||||
|
||||
run_case "non-root user 501 can write forced-owner style LIB_DIR when permissions allow" \
|
||||
"chown 0:0 /case/data /case/lib && chmod 777 /case/data /case/lib" \
|
||||
"-" "501:911" pass 501 911
|
||||
"-" "-" pass 911 911
|
||||
|
||||
run_case "1001-owned data auto-detected" \
|
||||
"chown 1001:1001 /case/data && chmod 755 /case/data" \
|
||||
"-" "-" pass 1001 1001
|
||||
|
||||
run_case "invalid nonnumeric PUID hard-errors" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data" \
|
||||
"PUID=abc PGID=911" "-" fail "" ""
|
||||
|
||||
run_case "root-owned ArchiveBox.conf only is repaired" \
|
||||
"chown 911:911 /case/data && chmod 755 /case/data && touch /case/data/ArchiveBox.conf /case/data/index.sqlite3 && chown 0:0 /case/data/ArchiveBox.conf /case/data/index.sqlite3" \
|
||||
"PUID=911 PGID=911" "-" pass 911 911 "$default_cmd" config-files-repaired
|
||||
|
||||
run_readonly_case "readonly bind mount hard-errors only when truly unwritable" \
|
||||
"chown 911:911 /case/data /case/lib /case/browsers && chmod 755 /case/data /case/lib /case/browsers" \
|
||||
"PUID=911 PGID=911" "-"
|
||||
|
||||
run_full_flow_case "prebuilt image init install version add update example.com" \
|
||||
"chown 0:0 /case/data && chmod 755 /case/data" \
|
||||
"PUID=911 PGID=911" "-" 911 911
|
||||
"-" "-" pass 911 911 "$default_cmd" config-files-repaired
|
||||
|
||||
run_mount_case "NFS" "${NFS_TEST_DIR:-}"
|
||||
run_mount_case "SMB" "${SMB_TEST_DIR:-}"
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/archivebox.demo}"
|
||||
DEPLOY_SERVICE="${DEPLOY_SERVICE:-archivebox}"
|
||||
DEPLOY_IMAGE="${DEPLOY_IMAGE:-archivebox/archivebox:dev}"
|
||||
DEPLOY_INTERVAL="${DEPLOY_INTERVAL:-60}"
|
||||
STATE_FILE="${STATE_FILE:-${DEPLOY_PATH}/.archivebox-dev-image.digest}"
|
||||
OVERRIDE_FILE="${OVERRIDE_FILE:-${DEPLOY_PATH}/.archivebox-deploy.override.yml}"
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
if [[ -f compose.yml ]]; then
|
||||
COMPOSE_FILE=compose.yml
|
||||
elif [[ -f compose.yaml ]]; then
|
||||
COMPOSE_FILE=compose.yaml
|
||||
elif [[ -f docker-compose.yml ]]; then
|
||||
COMPOSE_FILE=docker-compose.yml
|
||||
else
|
||||
echo "[X] No compose file found in $DEPLOY_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$OVERRIDE_FILE" <<EOF
|
||||
services:
|
||||
$DEPLOY_SERVICE:
|
||||
image: $DEPLOY_IMAGE
|
||||
EOF
|
||||
|
||||
COMPOSE=(docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE")
|
||||
|
||||
remote_digest() {
|
||||
docker buildx imagetools inspect "$DEPLOY_IMAGE" | awk '/^Digest:/ {print $2; exit}'
|
||||
}
|
||||
|
||||
deploy_digest() {
|
||||
local digest="$1"
|
||||
|
||||
echo "[+] Deploying ${DEPLOY_IMAGE}@${digest}"
|
||||
"${COMPOSE[@]}" pull "$DEPLOY_SERVICE"
|
||||
"${COMPOSE[@]}" up -d "$DEPLOY_SERVICE"
|
||||
|
||||
if "${COMPOSE[@]}" config --services | grep -qx argo; then
|
||||
"${COMPOSE[@]}" up -d argo
|
||||
fi
|
||||
|
||||
"${COMPOSE[@]}" exec -T "$DEPLOY_SERVICE" archivebox version </dev/null | sed -n '1,40p'
|
||||
"${COMPOSE[@]}" exec -T "$DEPLOY_SERVICE" curl -fsS --max-time 10 --connect-timeout 2 -H 'Host: admin.archivebox.io' http://127.0.0.1:8000/health/ </dev/null
|
||||
printf '%s\n' "$digest" > "$STATE_FILE"
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
touch "$STATE_FILE"
|
||||
|
||||
while :; do
|
||||
digest="$(remote_digest || true)"
|
||||
last_digest="$(cat "$STATE_FILE" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$digest" ]]; then
|
||||
echo "[!] Could not resolve ${DEPLOY_IMAGE}; retrying in ${DEPLOY_INTERVAL}s" >&2
|
||||
elif [[ "$digest" != "$last_digest" ]]; then
|
||||
deploy_digest "$digest"
|
||||
else
|
||||
echo "[=] ${DEPLOY_IMAGE} already deployed at ${digest}"
|
||||
fi
|
||||
|
||||
if [[ "${WATCH_ONCE:-0}" == "1" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep "$DEPLOY_INTERVAL"
|
||||
done
|
||||
@@ -23,9 +23,6 @@ services:
|
||||
- BASE_URL=${BASE_URL:-http://archivebox.localhost:8000} # public URL used to build admin/web/api/snapshot links
|
||||
- SERVER_SECURITY_MODE=${SERVER_SECURITY_MODE:-safe-subdomains-fullreplay} # safe-onedomain-nojsreplay if you can't do wildcard DNS *.your.domain
|
||||
- PUBLIC_ADD_VIEW=False # set to True to allow anonymous users to submit new URLs to archive
|
||||
# - PUID=911 # set to your host user's UID & GID if you encounter permissions issues
|
||||
# - PGID=911 # UID/GIDs lower than 500 may clash with system uids and are not recommended
|
||||
|
||||
# For all other options, it's better to use data/ArchiveBox.conf or the new Personas config feature in the admin UI...
|
||||
# - TIMEOUT=60
|
||||
# - CHECK_SSL_VALIDITY=False
|
||||
|
||||
+4
-16
@@ -597,19 +597,7 @@ Permissions to set on output files written into the archive directory. The direc
|
||||
> Set this to `600` if you want archives to be readable only by the ArchiveBox user, or `664`/`775` if you need a shared group to read/write the data dir.
|
||||
|
||||
*Related options:*
|
||||
[`PUID` / `PGID`](#puid--pgid), [`ENFORCE_ATOMIC_WRITES`](#enforce_atomic_writes)
|
||||
|
||||
---
|
||||
<a id="puid"></a>
|
||||
<a id="pgid"></a>
|
||||
#### `PUID` / `PGID`
|
||||
**Possible Values:** [`911`]/`1000`/...
|
||||
*Note: These are Docker-only environment variables — they only take effect when set on the Docker entrypoint at container startup. Setting them in `ArchiveBox.conf` or via `archivebox config --set` has no effect. Outside Docker the UID/GID is auto-detected from the ownership of the data directory (or the running user) and cannot be overridden.*
|
||||
|
||||
The UID/GID that the ArchiveBox process should run as (and that all files in the data dir should be owned by). Honored by the Docker entrypoint, which `chown`s the data dir and drops privileges before running ArchiveBox. Outside Docker, ArchiveBox refuses to run as root and instead drops to the user that owns the data dir.
|
||||
|
||||
*Learn more:*
|
||||
- https://docs.linuxserver.io/general/understanding-puid-and-pgid/
|
||||
[`ENFORCE_ATOMIC_WRITES`](#enforce_atomic_writes)
|
||||
- https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#docker-permissions-issues
|
||||
|
||||
---
|
||||
@@ -628,15 +616,15 @@ Path for temporary files, the supervisord unix socket, and generated supervisor
|
||||
If ArchiveBox detects the configured `TMP_DIR` is unwritable or too long, it will auto-fall-back to `/tmp/archivebox/<collection_id>` at startup.
|
||||
|
||||
*Related options:*
|
||||
[`LIB_DIR`](#lib_dir), [`ALLOW_NO_UNIX_SOCKETS`](#allow_no_unix_sockets)
|
||||
[`ABXPKG_LIB_DIR`](#abxpkg_lib_dir), [`ALLOW_NO_UNIX_SOCKETS`](#allow_no_unix_sockets)
|
||||
|
||||
---
|
||||
#### `LIB_DIR`
|
||||
#### `ABXPKG_LIB_DIR`
|
||||
**Possible Values:** [`<user-config>/abx/lib`]/`/opt/archivebox/lib`/`~/.config/abx/lib`/...
|
||||
Path for installed binary dependencies (`chromium`, `single-file`, `yt-dlp`, `ripgrep`, etc.) managed by `abxpkg`. The default is the platform-standard user-config location (`~/.config/abx/lib` on Linux, `~/Library/Application Support/abx/lib` on macOS, `%APPDATA%\abx\lib` on Windows) so a single binary installation can be shared across multiple collections on the same machine without re-downloading.
|
||||
|
||||
> [!NOTE]
|
||||
> `LIB_DIR` can grow to several GB. Put it on a fast local disk — running extractors off a network-mounted `LIB_DIR` will be painfully slow.
|
||||
> `ABXPKG_LIB_DIR` can grow to several GB. Put it on a fast local disk — running extractors off a network-mounted `ABXPKG_LIB_DIR` will be painfully slow.
|
||||
|
||||
*Related options:*
|
||||
[`TMP_DIR`](#tmp_dir)
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ docker run -it -v $PWD:/data archivebox/archivebox init --setup
|
||||
|
||||
*(You can create a collection in any directory you want, `~/archivebox/data` is just used as an example here)*
|
||||
|
||||
If you encounter permissions issues, you may need configure user/group ownership explicitly with [`PUID`/`PGID`](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid).
|
||||
If you encounter permissions issues, make sure the mounted data directory is writable by its intended owner. Docker startup automatically uses the first non-root owner detected from the existing collection, or the default `archivebox` user when the data directory is root-owned.
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ Unless `--yes --delete` is passed to `archivebox remove`, Snapshots removed from
|
||||
|
||||
Consider what permissioning to apply to your archive folder carefully. Limit access to the fewest possible users by checking folder ownership and setting [`OUTPUT_PERMISSIONS`](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#output_permissions) accordingly. Generally the `index.sqlite3` file, `archive/` folder, and `ArchiveBox.conf` file must all be owned and writable by the `archivebox` user or a dedicated non-root user.
|
||||
|
||||
[`PUID` & `PGID`](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid) can be set when running with Docker to control what user and group ArchiveBox expects to own the data directory within the container.
|
||||
When running with Docker, the entrypoint uses the existing non-root owner of the mounted data directory when possible, otherwise it falls back to the image's `archivebox` user.
|
||||
|
||||
More info:
|
||||
- https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#disk-layout
|
||||
|
||||
@@ -170,7 +170,7 @@ if you have problem with a particular nginx config.
|
||||
|
||||
#### Docker Permissions issues
|
||||
|
||||
Try Setting `PUID` & `PGID`: https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid
|
||||
Make sure the mounted data directory is writable by the user that owns it. The `archivebox` username only exists inside the Docker container, so on the host you should check numeric ownership instead. For a new or root-owned Docker data directory, make sure it is writable by UID/GID `911:911`.
|
||||
|
||||
Try using [`bindfs`](https://github.com/clecherbauer/docker-volume-bindfs) to work around issues by remapping permissions, for example to remap `uid:33 gid:33` on the host to `911:911` inside the container:
|
||||
`docker-compose.yml`:
|
||||
|
||||
@@ -451,13 +451,13 @@ Bases: {py:obj}`archivebox.config.configset.BaseConfigSet`
|
||||
|
||||
````
|
||||
|
||||
````{py:attribute} LIB_DIR
|
||||
:canonical: archivebox.config.common.StorageConfig.LIB_DIR
|
||||
````{py:attribute} ABXPKG_LIB_DIR
|
||||
:canonical: archivebox.config.common.StorageConfig.ABXPKG_LIB_DIR
|
||||
:type: pathlib.Path
|
||||
:value: >
|
||||
'Field(...)'
|
||||
|
||||
```{autodoc2-docstring} archivebox.config.common.StorageConfig.LIB_DIR
|
||||
```{autodoc2-docstring} archivebox.config.common.StorageConfig.ABXPKG_LIB_DIR
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user