fix: make portable exports and screenshot deploys reliable

This commit is contained in:
Nick Sweeting 2026-08-29 16:03:21 -07:00
parent 2d37ab29e4
commit 6906edc076
No known key found for this signature in database
21 changed files with 680 additions and 617 deletions

View File

@ -50,6 +50,22 @@ jobs:
env:
BASE_URL: http://archivebox.localhost:8000
- name: Verify gallery was generated from this exact revision
run: |
uv run --no-cache --no-sync python - <<'PY'
import json
import os
import tomllib
from pathlib import Path
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
assert provenance["version"] == version, provenance
assert provenance["capture_count"] >= 3 * 35, provenance
assert len(provenance["files"]) == provenance["capture_count"], provenance
PY
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5

View File

@ -682,7 +682,7 @@ It uses all available methods out-of-the-box, but you can disable extractors and
<summary><i>Expand to see the full list of ways it saves each page...</i></summary>
<code>data/archive/{Snapshot.id}/</code><br/>
<code>data/archive/users/{username}/snapshots/{YYYYMMDD}/{domain}/{Snapshot.id}/</code><br/>
<ul>
<li><strong>Index:</strong> <code>index.html</code> &amp; <code>index.json</code> HTML and JSON index files containing metadata and details</li>
<li><strong>Title</strong>, <strong>Favicon</strong>, <strong>Headers</strong> Response headers, site favicon, and parsed site title</li>
@ -849,7 +849,7 @@ The on-disk layout is optimized to be easy to browse by hand and durable long-te
...
</code></pre>
Each snapshot subfolder includes static metadata and plain extractor output files. ArchiveBox also maintains a backwards-compatible <code>data/archive/TIMESTAMP</code> symlink for each snapshot.
Each snapshot subfolder includes static metadata and plain extractor output files. Current releases do not create top-level <code>data/archive/TIMESTAMP</code> projections; legacy timestamp directories are migrated into the user-scoped tree by <code>archivebox update --migrate-only</code>.
<h4>Learn More</h4>
<ul>

View File

@ -561,21 +561,20 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
"""
Drain old archive/ directories (0.8.x 0.9.x migration).
Only processes real directories (skips symlinks - those are already migrated).
Removes obsolete timestamp symlinks and processes real legacy directories.
For each old dir found in archive/:
1. Load or create DB snapshot
2. Trigger fs migration on save() to move to data/archive/users/{user}/...
3. Leave symlink in archive/ pointing to new location
3. Remove the old timestamp path after the verified migration commits
After this drains, archive/ should only contain symlinks and we can trust
1:1 mapping between DB and filesystem.
After this drains, current snapshot data exists only under archive/users/.
"""
from archivebox.core.models import Snapshot
from archivebox.config import CONSTANTS
from archivebox.crawls.models import Crawl
from django.utils import timezone
stats = {"processed": 0, "migrated": 0, "queued": 0, "skipped": 0, "invalid": 0}
stats = {"processed": 0, "migrated": 0, "queued": 0, "skipped": 0, "invalid": 0, "removed_symlinks": 0}
crawl_url_lines: dict[str, list[str]] = {}
crawl_url_sets: dict[str, set[str]] = {}
dirty_crawl_ids: set[str] = set()
@ -606,8 +605,23 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
if changed:
Crawl.objects.filter(pk=crawl.pk).update(urls="\n".join(lines), modified_at=timezone.now())
# Scan for real directories only (skip symlinks - they're already migrated)
# Compatibility timestamp projections are harmful in portable exports and
# duplicate the obsolete 0.7.x-looking namespace without containing data.
all_entries = list(os.scandir(archive_dir))
for entry in all_entries:
entry_path = Path(entry.path)
if entry.is_symlink() and Snapshot.is_legacy_archive_dir(entry_path):
try:
target_path = entry_path.resolve(strict=True)
points_into_current_layout = target_path.is_relative_to(CONSTANTS.USERS_DIR.resolve(strict=True))
except OSError:
points_into_current_layout = False
if points_into_current_layout:
entry_path.unlink(missing_ok=True)
stats["removed_symlinks"] += 1
# Scan real legacy directories only; these still contain data to migrate.
entries = [
(e.stat().st_mtime, e.path)
for e in all_entries

View File

@ -46,8 +46,6 @@ from archivebox.misc.util import (
sanitize_html_text,
to_json,
ts_to_date_str,
urldecode,
urlencode,
validate_url,
)
from archivebox.plugins.discovery import (
@ -427,7 +425,7 @@ class SnapshotQuerySet(models.QuerySet):
else {}
)
snapshot_dicts = [s.to_dict(extended=True) for s in self.iterator(chunk_size=500)]
snapshot_dicts = [s.to_dict(extended=True, static_export=True) for s in self.iterator(chunk_size=500)]
if with_headers:
output = {
@ -461,6 +459,13 @@ class SnapshotQuerySet(models.QuerySet):
template = "static_index.html" if with_headers else "minimal_index.html"
snapshot_list = list(self.iterator(chunk_size=500))
for snapshot in snapshot_list:
outputs = snapshot.discover_outputs(include_filesystem_fallback=True)
output_paths = [str(output.get("path") or "") for output in outputs]
snapshot._public_preview_paths = [
path for preferred in ("screenshot/screenshot.png", "screenshot.png") for path in output_paths if path == preferred
]
snapshot._public_favicon_paths = [path for path in output_paths if path in ("favicon/favicon.ico", "favicon.ico")]
return render_to_string(
template,
@ -472,6 +477,8 @@ class SnapshotQuerySet(models.QuerySet):
"time_updated": datetime.now(UTC).strftime("%Y-%m-%d %H:%M"),
"links": snapshot_list,
"FOOTER_INFO": config.FOOTER_INFO,
"STATIC_EXPORT": True,
"STATIC_EXPORT_DIR": CONSTANTS.DATA_DIR,
},
)
@ -984,7 +991,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
from django.db import transaction
def finish_snapshot_save():
self.ensure_legacy_archive_symlink()
self.remove_legacy_archive_symlink()
self.ensure_crawl_symlink()
crawl = Crawl.objects.filter(pk=self.crawl_id).first()
if crawl is None:
@ -1261,9 +1268,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return (old_dir, new_dir)
def _cleanup_old_migration_dir(self, old_dir: Path, new_dir: Path):
"""
Delete old directory and create symlink after successful migration.
"""
"""Delete the old directory after its contents are verified at the new path."""
import logging
import shutil
@ -1280,20 +1285,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
logging.getLogger("archivebox.migration").warning(
f"Could not remove old migration directory {old_dir}: {e}",
)
return # Don't create symlink if cleanup failed
return
# Create backwards-compat symlink (after old dir is deleted)
symlink_path = old_dir # Same path as old_dir
if symlink_path.is_symlink():
symlink_path.unlink()
if not symlink_path.exists():
try:
symlink_path.symlink_to(new_dir, target_is_directory=True)
except OSError as e:
logging.getLogger("archivebox.migration").warning(
f"Could not create symlink from {symlink_path} to {new_dir}: {e}",
)
# Older migration runs may already have left a timestamp projection.
if old_dir.is_symlink():
old_dir.unlink(missing_ok=True)
# =========================================================================
# Path Calculation and Migration Helpers
@ -2273,7 +2269,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return calc_tags_str()
return calc_tags_str()
def icons(self, path: str | None = None) -> str:
def icons(self, path: str | None = None, prefix: str = "/") -> str:
"""Generate HTML icons showing which extractor plugins have succeeded for this snapshot"""
from django.utils.html import format_html
@ -2360,7 +2356,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
archive_path = path or self.archive_path
output = ""
output_template = '<a href="/{}/{}" class="exists-{}" title="{}">{}</a>'
output_template = '<a href="{}{}/{}" class="exists-{}" title="{}">{}</a>'
# Get all plugins from hooks system (sorted by numeric prefix)
all_plugins = self.__dict__.get("_icons_plugin_names")
@ -2388,6 +2384,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
embed_path = f"{plugin}/" if compact_icons else result.embed_path()
output += format_html(
output_template,
prefix,
archive_path,
embed_path,
str(bool(existing)),
@ -2538,35 +2535,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return current_path
def ensure_legacy_archive_symlink(self) -> None:
"""Ensure the legacy archive/<timestamp> path resolves to this snapshot."""
import os
legacy_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
target = Path(self.get_storage_path_for_version(self._fs_current_version()))
if target == legacy_path:
return
legacy_path.parent.mkdir(parents=True, exist_ok=True)
if legacy_path.exists() or legacy_path.is_symlink():
if legacy_path.is_symlink():
try:
if legacy_path.resolve() == target.resolve():
return
except OSError:
pass
legacy_path.unlink(missing_ok=True)
else:
return
rel_target = os.path.relpath(target, legacy_path.parent)
try:
legacy_path.symlink_to(rel_target, target_is_directory=True)
except OSError:
return
def ensure_crawl_symlink(self, *, crawl_dir: Path | None = None, snapshot_dir: Path | None = None) -> None:
"""Ensure snapshot is symlinked under its crawl output directory."""
import os
@ -2606,6 +2574,21 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
except OSError:
return
def remove_legacy_archive_symlink(self) -> None:
"""Remove a stale archive/<timestamp> compatibility projection."""
legacy_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
current_path = self.get_storage_path_for_version(self._fs_current_version())
if not legacy_path.is_symlink() or not current_path.exists():
return
try:
points_to_current_path = legacy_path.resolve(strict=True) == current_path.resolve(strict=True)
except OSError:
points_to_current_path = False
if points_to_current_path:
legacy_path.unlink(missing_ok=True)
@cached_property
def legacy_archive_path(self) -> str:
return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{self.timestamp}"
@ -3437,7 +3420,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
# Serialization Methods
# =========================================================================
def to_dict(self, extended: bool = False) -> dict[str, Any]:
@property
def static_archive_path(self) -> str:
"""Snapshot output path relative to the data root, for portable exports."""
try:
return Path(self.output_dir).relative_to(CONSTANTS.DATA_DIR).as_posix()
except ValueError:
return Path(self.output_dir).as_posix()
def to_dict(self, extended: bool = False, static_export: bool = False) -> dict[str, Any]:
"""Convert Snapshot to a dictionary (replacement for Link._asdict())"""
from archivebox.core.routes_util import build_snapshot_url
@ -3468,8 +3459,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"extension": self.extension,
"is_static": self.is_static,
"is_archived": self.is_archived,
"archive_path": self.archive_path,
"archive_url": build_snapshot_url(str(self.id), "index.html"),
"archive_path": self.static_archive_path if static_export else self.archive_path,
"archive_url": f"./{self.static_archive_path}/index.html" if static_export else build_snapshot_url(str(self.id), "index.html"),
"output_dir": self.output_dir,
"link_dir": self.output_dir, # backwards compatibility alias
"archive_size": archive_size,
@ -3499,67 +3490,154 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"""Write JSON index file for this snapshot to its output directory"""
output_dir = Path(out_dir) if out_dir is not None else self.output_dir
path = output_dir / CONSTANTS.JSON_INDEX_FILENAME
atomic_write(str(path), self.to_dict(extended=True))
def write_html_details(self, out_dir: Path | str | None = None) -> None:
"""Write HTML detail page for this snapshot to its output directory"""
from django.template.loader import render_to_string
atomic_write(str(path), self.to_dict(extended=True, static_export=True))
def get_html_details_context(self, request=None, *, static_export_dir: Path | None = None) -> dict[str, Any]:
"""Build the one context used by both served and on-disk snapshot pages."""
from archivebox.config.common import get_request_config
from archivebox.core.permissions import get_snapshot_permissions
from archivebox.core.widgets import TagEditorWidget
from archivebox.misc.logging_util import printable_filesize
from archivebox.progressmonitor.views import progress_endpoint
output_dir = Path(out_dir) if out_dir is not None else self.output_dir
TITLE_LOADING_MSG = "Not yet archived..."
runtime_config = get_request_config(request) if request is not None else get_config()
self._runtime_config = runtime_config
snapshot_permissions = get_snapshot_permissions(self)
archive_results = list(self.archiveresult_set.all().order_by("start_ts"))
tags = list(self.tags.all())
self.__dict__["_admin_archiveresults"] = archive_results
self.__dict__["_tags_str_cached"] = ",".join(sorted(tag.name for tag in tags))
self.__dict__["num_outputs_cached"] = sum(result.status == ArchiveResult.StatusChoices.SUCCEEDED for result in archive_results)
self.__dict__["num_failures_cached"] = sum(result.status == ArchiveResult.StatusChoices.FAILED for result in archive_results)
preview_priority = [
"singlefile",
"screenshot",
"wget",
"dom",
"pdf",
"readability",
hidden_card_plugins = {"archivedotorg", "favicon", "title"}
outputs = [
output
for output in self.discover_outputs(include_filesystem_fallback=True, archive_results=archive_results)
if (output.get("size") or 0) > 0 and output.get("name") not in hidden_card_plugins
]
outputs_by_name: dict[str, dict[str, Any]] = {}
result_ids_by_name: dict[str, list[str]] = {}
for output in outputs:
if output.get("result"):
result_ids_by_name.setdefault(output["name"], []).append(str(output["result"].id))
current = outputs_by_name.get(output["name"])
if current is None or (output.get("size") or 0) > (current.get("size") or 0):
outputs_by_name[output["name"]] = output
for name, output in outputs_by_name.items():
output["result_ids"] = ",".join(result_ids_by_name.get(name, ()))
outputs = self.discover_outputs(include_filesystem_fallback=True)
loose_items, failed_items = self.get_detail_page_auxiliary_items(outputs)
outputs_by_plugin = {out["name"]: out for out in outputs}
output_size = sum(int(out.get("size") or 0) for out in outputs)
is_archived = bool(outputs or self.downloaded_at or self.status == self.StatusChoices.SEALED)
best_preview_path = "about:blank"
hash_index = self.hashes_index
loose_items, failed_items = self.get_detail_page_auxiliary_items(
outputs,
hidden_card_plugins=hidden_card_plugins,
archive_results=archive_results,
)
preview_priority = ("singlefile", "screenshot", "wget", "dom", "pdf", "readability")
output_order = {result_type: index for index, result_type in enumerate(outputs_by_name)}
ordered_outputs = sorted(
outputs_by_name.values(),
key=lambda output: (
preview_priority.index(output["name"]) if output["name"] in preview_priority else len(preview_priority),
output_order.get(output["name"], len(output_order)),
),
)
best_result = {"path": "about:blank", "result": None}
for plugin in preview_priority:
out = outputs_by_plugin.get(plugin)
if out and out.get("path"):
best_preview_path = str(out["path"])
best_result = out
for result_type in preview_priority:
if result_type in outputs_by_name:
best_result = outputs_by_name[result_type]
break
if best_result["path"] == "about:blank" and ordered_outputs:
best_result = ordered_outputs[0]
if best_preview_path == "about:blank" and outputs:
best_preview_path = str(outputs[0].get("path") or "about:blank")
best_result = outputs[0]
non_compact_outputs = [output for output in ordered_outputs if not output.get("is_compact") and not output.get("is_metadata")]
compact_outputs = [output for output in ordered_outputs if output.get("is_compact") or output.get("is_metadata")]
archive_dates = [result.start_ts for result in archive_results if result.start_ts]
output_size = sum(int(output.get("size") or 0) for output in ordered_outputs)
has_outputs = bool(ordered_outputs)
is_archived = has_outputs or self.status == self.StatusChoices.SEALED
snapshot_status = str(self.status or "").lower()
status_label_by_state = {
"queued": ("queued", "info"),
"started": ("running", "warning"),
"paused": ("paused", "default"),
"sealed": ("archived", "success"),
}
if has_outputs:
status_label, status_color = ("archived", "success") if is_archived else ("partial", "warning")
else:
status_label, status_color = status_label_by_state.get(snapshot_status, ("not yet archived", "danger"))
related_snapshots = list(
type(self)
.objects.filter(url=self.url)
.exclude(id=self.id)
.only("id", "url", "bookmarked_at", "created_at", "downloaded_at", "output_size")
.order_by("-bookmarked_at", "-created_at", "-timestamp")[:25],
)
related_years_map: dict[int, list[Snapshot]] = {}
for snapshot in [self, *related_snapshots]:
snapshot_date = snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at
if snapshot_date:
related_years_map.setdefault(snapshot_date.year, []).append(snapshot)
related_years = []
for year, snapshots in related_years_map.items():
snapshots.sort(
key=lambda snapshot: snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at or timezone.now(),
reverse=True,
)
related_years.append({"year": year, "latest": snapshots[0], "snapshots": snapshots})
related_years.sort(key=lambda item: item["year"], reverse=True)
warc_path = next(
(rel_path for rel_path in hash_index if rel_path.startswith("warc/") and ".warc" in Path(rel_path).name),
"warc/",
)
user = getattr(request, "user", None)
tag_widget = TagEditorWidget()
context = {
**self.to_dict(extended=True),
"snapshot": self,
"title": htmldecode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
"url_str": htmldecode(urldecode(self.base_url)),
"archive_url": urlencode(f"warc/{self.timestamp}") or "about:blank",
return {
"id": str(self.id),
"snapshot_id": str(self.id),
"progress_endpoint": progress_endpoint("snapshot", self.id) if request is not None else "",
"progress_auto_expand": snapshot_status in {"queued", "started", "paused"},
"url": self.url,
"archive_path": self.archive_path_from_db,
"title": htmldecode(self.resolved_title or (self.base_url if is_archived else "Not yet archived...")),
"extension": self.extension or "html",
"tags": self.tags_str() or "untagged",
"size": printable_filesize(output_size) if output_size else "pending",
"status": "archived" if is_archived else "not yet archived",
"status_color": "success" if is_archived else "danger",
"oldest_archive_date": ts_to_date_str(self.oldest_archive_date),
"best_preview_path": best_preview_path,
"size": printable_filesize(output_size) if output_size else "",
"status": status_label,
"status_color": status_color,
"snapshot_state": snapshot_status,
"has_outputs": has_outputs,
"snapshot_permissions": snapshot_permissions,
"snapshot_permissions_icon": {"public": "👥", "unlisted": "🔗", "private": "🔒"}.get(snapshot_permissions, "👥"),
"bookmarked_date": self.bookmarked_date,
"downloaded_datestr": self.downloaded_datestr,
"num_outputs": self.num_outputs,
"num_failures": self.num_failures,
"oldest_archive_date": ts_to_date_str(min(archive_dates) if archive_dates else None),
"warc_path": warc_path,
"archiveresults": [*non_compact_outputs, *compact_outputs],
"best_result": best_result,
"archiveresults": outputs,
"snapshot": self,
"CONFIG": runtime_config,
"related_snapshots": related_snapshots,
"related_years": related_years,
"loose_items": loose_items,
"failed_items": failed_items,
"related_snapshots": [],
"related_years": [],
"title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in self.tags.all().order_by("name")],
"can_delete_outputs": bool(user and user.is_authenticated and user.is_active and user.is_superuser),
"title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in sorted(tags, key=lambda tag: tag.name)],
"STATIC_EXPORT": static_export_dir is not None,
"STATIC_EXPORT_DIR": static_export_dir,
}
def write_html_details(self, out_dir: Path | str | None = None) -> None:
"""Write the unified snapshot detail page with portable filesystem URLs."""
from django.template.loader import render_to_string
output_dir = Path(out_dir) if out_dir is not None else self.output_dir
context = self.get_html_details_context(static_export_dir=output_dir)
rendered_html = render_to_string("core/snapshot.html", context)
atomic_write(str(output_dir / CONSTANTS.HTML_INDEX_FILENAME), rendered_html)

View File

@ -2,6 +2,7 @@ import os
from html import unescape
from pathlib import Path
from typing import Any
from urllib.parse import quote
from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target
from django import template
@ -11,6 +12,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.text import Truncator
from archivebox.config import CONSTANTS
from archivebox.core.routes_util import (
build_snapshot_url,
get_admin_base_url,
@ -28,6 +30,7 @@ register = template.Library()
_TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml", ".yaml", ".md", ".log")
_IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif")
_STATIC_URL_SAFE = "/@-._~!$&'()*+,;="
_MEDIA_FILE_EXTS = {
".mp4",
@ -202,16 +205,70 @@ def _is_root_snapshot_output_path(raw_output_path: str | None) -> bool:
return normalized in ("", ".", "./", "/", "index.html", "index.json")
def _build_snapshot_files_url(snapshot_id: str, request=None, config=None) -> str:
return build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
def _static_snapshot_base_url(context, snapshot) -> str:
start_dir = Path(context["STATIC_EXPORT_DIR"])
relative_path = os.path.relpath(Path(snapshot.output_dir), start=start_dir).replace(os.sep, "/")
if relative_path == ".":
return "."
return f"./{quote(relative_path, safe=_STATIC_URL_SAFE)}"
def _build_snapshot_preview_url(snapshot_id: str, path: str = "", request=None, config=None, plugin: str = "") -> str:
def _snapshot_base_url_for_context(context, snapshot) -> str:
if context.get("STATIC_EXPORT"):
return _static_snapshot_base_url(context, snapshot)
return get_snapshot_base_url(
str(_snapshot_id(snapshot)),
request=context.get("request"),
config=context.get("CONFIG"),
)
def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
if not context.get("STATIC_EXPORT"):
return build_snapshot_url(
str(_snapshot_id(snapshot)),
path,
request=context.get("request"),
config=context.get("CONFIG"),
)
base_url = _static_snapshot_base_url(context, snapshot)
raw_path = str(path or "")
if not raw_path:
return base_url
path_part, separator, query = raw_path.lstrip("/").partition("?")
quoted_path = quote(path_part, safe=_STATIC_URL_SAFE)
suffix = f"?{query}" if separator else ""
return f"{base_url.rstrip('/')}/{quoted_path}{suffix}"
def _build_snapshot_files_url(snapshot_id: str, request=None, config=None, base_url: str | None = None) -> str:
return (
f"{base_url.rstrip('/')}/?files=1"
if base_url
else build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
)
def _build_snapshot_preview_url(
snapshot_id: str,
path: str = "",
request=None,
config=None,
plugin: str = "",
base_url: str | None = None,
) -> str:
if path == "about:blank":
return path
if _is_root_snapshot_output_path(path):
return _build_snapshot_files_url(snapshot_id, request=request, config=config)
url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
return _build_snapshot_files_url(snapshot_id, request=request, config=config, base_url=base_url)
if base_url:
path_part, separator, query = str(path).lstrip("/").partition("?")
url = f"{base_url.rstrip('/')}/{quote(path_part, safe=_STATIC_URL_SAFE)}"
if separator:
url = f"{url}?{query}"
else:
url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
path_parts = Path(path).parts
plugin = get_plugin_name(plugin) if plugin else (path_parts[0] if len(path_parts) > 1 else "")
has_plugin_preview = bool(plugin and get_plugin_template(plugin, "full", fallback=False))
@ -441,23 +498,35 @@ def web_base_url(context) -> str:
return get_web_base_url(request=context.get("request"), config=context.get("CONFIG"))
@register.simple_tag(takes_context=True)
def static_export_root_url(context) -> str:
if not context.get("STATIC_EXPORT"):
return ""
relative_path = os.path.relpath(CONSTANTS.DATA_DIR, start=Path(context["STATIC_EXPORT_DIR"]))
relative_path = relative_path.replace(os.sep, "/")
return "./" if relative_path == "." else f"{quote(relative_path, safe=_STATIC_URL_SAFE)}/"
@register.simple_tag(takes_context=True)
def snapshot_base_url(context, snapshot) -> str:
snapshot_id = _snapshot_id(snapshot)
return get_snapshot_base_url(str(snapshot_id), request=context.get("request"), config=context.get("CONFIG"))
return _snapshot_base_url_for_context(context, snapshot)
@register.simple_tag(takes_context=True)
def snapshot_url(context, snapshot, path: str = "") -> str:
snapshot_id = _snapshot_id(snapshot)
return build_snapshot_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
return _snapshot_url_for_context(context, snapshot, path)
@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)
cache_key = (
plugin,
filename,
bool(context.get("STATIC_EXPORT")),
str(context.get("STATIC_EXPORT_DIR") or ""),
)
if cache_key in url_cache:
return url_cache[cache_key]
@ -493,7 +562,7 @@ def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) ->
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"))
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path)
return url_cache[cache_key]
url_cache[cache_key] = ""
@ -502,10 +571,7 @@ def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) ->
@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)
snapshot_base = _snapshot_base_url_for_context(context, link)
status = getattr(link, "status", None) or "unknown"
bookmarked_at = getattr(link, "bookmarked_at", None)
@ -534,22 +600,28 @@ def snapshot_index_row(context, link) -> str:
tag_cell = '<span class="empty-value">...</span>'
num_outputs = int(getattr(link, "num_outputs", 0) or 0)
icons = link.icons() if callable(getattr(link, "icons", None)) else getattr(link, "icons", "")
if context.get("STATIC_EXPORT") and callable(getattr(link, "icons", None)):
icons = link.icons(path=quote(link.static_archive_path, safe=_STATIC_URL_SAFE), prefix="./")
else:
icons = link.icons() if callable(getattr(link, "icons", None)) else getattr(link, "icons", "")
icons_cell = str(icons) if icons else '<span class="empty-value">...</span>'
archive_size = int(getattr(link, "archive_size", 0) or 0)
size_cell = file_size(archive_size) if archive_size else '<span class="empty-value">...</span>'
output_plural = "" if num_outputs == 1 else "s"
files_url = _snapshot_url_for_context(context, link, "index.jsonl") if context.get("STATIC_EXPORT") else f"{snapshot_base}/?files=1"
if is_pending:
preview_html = (
'<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress">'
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
"</span>"
)
preview_html = '<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress"></span>'
if not context.get("STATIC_EXPORT"):
preview_html = (
'<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress">'
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_urls = [_snapshot_url_for_context(context, link, path) for path in preview_paths]
preview_html = (
f'<img src="{escape(preview_urls[0])}" '
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
@ -579,7 +651,7 @@ def snapshot_index_row(context, link) -> str:
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_urls = [_snapshot_url_for_context(context, link, path) for path in favicon_paths]
favicon_html = (
f'<img src="{escape(favicon_urls[0])}" '
f'data-fallbacks="{escape(",".join(favicon_urls[1:]))}" '
@ -640,7 +712,7 @@ def snapshot_index_row(context, link) -> str:
</span>
</td>
<td class="snapshot-size-cell">
<a href="{escape(snapshot_base)}/?files=1" title="View archived files">
<a href="{escape(files_url)}" title="View archived file manifest">
{size_cell}
</a>
<small>{num_outputs} output{output_plural}</small>
@ -660,6 +732,7 @@ def snapshot_preview_url(context, snapshot, path: str = "", result=None) -> str:
request=context.get("request"),
config=context.get("CONFIG"),
plugin=plugin,
base_url=_snapshot_base_url_for_context(context, snapshot) if context.get("STATIC_EXPORT") else None,
)
@ -699,24 +772,16 @@ def plugin_card(context, result) -> str:
# Use embed_path() for the display path
raw_output_path = result.embed_path() or ""
output_url = build_snapshot_url(
str(result.snapshot_id),
raw_output_path or "",
request=context.get("request"),
config=context.get("CONFIG"),
)
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path or "")
icon_html = get_plugin_icon(plugin)
plugin_lower = (plugin or "").lower()
media_file_count = _count_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else 0
media_files = _list_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else []
if media_files:
snapshot_id = str(result.snapshot_id)
request = context.get("request")
config = context.get("CONFIG")
for item in media_files:
path = item.get("path") or ""
item["url"] = build_snapshot_url(snapshot_id, path, request=request, config=config) if path else ""
item["url"] = _snapshot_url_for_context(context, result.snapshot, path) if path else ""
output_lower = (raw_output_path or "").lower()
force_text_preview = output_lower.endswith(_TEXT_PREVIEW_EXTS)
@ -794,12 +859,7 @@ def plugin_full(context, result) -> str:
raw_output_path = result.embed_path() or ""
if _is_root_snapshot_output_path(raw_output_path):
return ""
output_url = build_snapshot_url(
str(result.snapshot_id),
raw_output_path,
request=context.get("request"),
config=context.get("CONFIG"),
)
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path)
try:
tpl = template.Template(template_str)

View File

@ -21,7 +21,6 @@ from django.core.paginator import InvalidPage
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidden, QueryDict
from django.shortcuts import redirect, render
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
@ -54,7 +53,6 @@ from archivebox.core.permissions import (
can_view_snapshot,
direct_snapshots_queryset,
filter_personas_by_permissions,
get_snapshot_permissions,
is_admin_user,
public_snapshots_queryset,
)
@ -69,15 +67,12 @@ from archivebox.core.routes_util import (
host_matches,
)
from archivebox.crawls.models import Crawl
from archivebox.misc.logging_util import printable_filesize
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.serve_static import serve_static_with_byterange_support
from archivebox.misc.util import (
base_url,
filter_queryset_by_uuid_substring,
htmldecode,
sanitize_html_text,
ts_to_date_str,
urldecode,
validate_url,
without_fragment,
@ -85,7 +80,7 @@ from archivebox.misc.util import (
from archivebox.plugins.discovery import get_plugin_name, get_plugin_template
from archivebox.plugins.forms import get_plugin_config_binary_urls
from archivebox.plugins.views import get_config_definition_link
from archivebox.progressmonitor.views import live_progress_view, progress_endpoint
from archivebox.progressmonitor.views import live_progress_view
from archivebox.search.config import (
get_search_mode,
get_search_mode_backend,
@ -332,162 +327,11 @@ class SnapshotView(View):
@staticmethod
def render_live_index(request, snapshot):
TITLE_LOADING_MSG = "Not yet archived..."
from archivebox.core.widgets import TagEditorWidget
# Reuse the middleware-attached config; never re-bootstrap from env + plugin
# schemas just to render a snapshot page (that pays ~30ms for no reason).
runtime_config = get_request_config(request)
snapshot._runtime_config = runtime_config
snapshot_permissions = get_snapshot_permissions(snapshot)
archive_results = list(snapshot.archiveresult_set.all().order_by("start_ts"))
tags = list(snapshot.tags.all())
snapshot.__dict__["_admin_archiveresults"] = archive_results
snapshot.__dict__["_tags_str_cached"] = ",".join(sorted(tag.name for tag in tags))
snapshot.__dict__["num_outputs_cached"] = sum(result.status == ArchiveResult.StatusChoices.SUCCEEDED for result in archive_results)
snapshot.__dict__["num_failures_cached"] = sum(result.status == ArchiveResult.StatusChoices.FAILED for result in archive_results)
hidden_card_plugins = {"archivedotorg", "favicon", "title"}
outputs = [
out
for out in snapshot.discover_outputs(include_filesystem_fallback=True, archive_results=archive_results)
if (out.get("size") or 0) > 0 and out.get("name") not in hidden_card_plugins
]
archiveresults = {}
result_ids_by_name = {}
for output in outputs:
if output.get("result"):
result_ids_by_name.setdefault(output["name"], []).append(str(output["result"].id))
current = archiveresults.get(output["name"])
if current is None or (output.get("size") or 0) > (current.get("size") or 0):
archiveresults[output["name"]] = output
for name, output in archiveresults.items():
output["result_ids"] = ",".join(result_ids_by_name.get(name, ()))
hash_index = snapshot.hashes_index
loose_items, failed_items = snapshot.get_detail_page_auxiliary_items(
outputs,
hidden_card_plugins=hidden_card_plugins,
archive_results=archive_results,
return render(
template_name="core/snapshot.html",
request=request,
context=snapshot.get_html_details_context(request=request),
)
preview_priority = [
"singlefile",
"screenshot",
"wget",
"dom",
"pdf",
"readability",
]
preferred_types = tuple(preview_priority)
output_order = {result_type: index for index, result_type in enumerate(archiveresults.keys())}
best_result = {"path": "about:blank", "result": None}
for result_type in preferred_types:
if result_type in archiveresults:
best_result = archiveresults[result_type]
break
related_snapshots_qs = SnapshotView.find_snapshots_for_url(
snapshot.url,
allow_fallback=False,
).only("id", "url", "bookmarked_at", "created_at", "downloaded_at", "output_size")
related_snapshots = list(
related_snapshots_qs.exclude(id=snapshot.id).order_by("-bookmarked_at", "-created_at", "-timestamp")[:25],
)
related_years_map: dict[int, list[Snapshot]] = {}
for snap in [snapshot, *related_snapshots]:
snap_dt = snap.bookmarked_at or snap.created_at or snap.downloaded_at
if not snap_dt:
continue
related_years_map.setdefault(snap_dt.year, []).append(snap)
related_years = []
for year, snaps in related_years_map.items():
snaps_sorted = sorted(
snaps,
key=lambda s: s.bookmarked_at or s.created_at or s.downloaded_at or timezone.now(),
reverse=True,
)
related_years.append(
{
"year": year,
"latest": snaps_sorted[0],
"snapshots": snaps_sorted,
},
)
related_years.sort(key=lambda item: item["year"], reverse=True)
warc_path = next(
(rel_path for rel_path in hash_index if rel_path.startswith("warc/") and ".warc" in Path(rel_path).name),
"warc/",
)
ordered_outputs = sorted(
archiveresults.values(),
key=lambda r: (
preferred_types.index(r["name"]) if r["name"] in preferred_types else len(preferred_types),
output_order.get(r["name"], len(output_order)),
),
)
if best_result["path"] == "about:blank" and ordered_outputs:
best_result = ordered_outputs[0]
non_compact_outputs = [out for out in ordered_outputs if not out.get("is_compact") and not out.get("is_metadata")]
compact_outputs = [out for out in ordered_outputs if out.get("is_compact") or out.get("is_metadata")]
tag_widget = TagEditorWidget()
output_size = sum(int(out.get("size") or 0) for out in ordered_outputs)
archive_dates = [result.start_ts for result in archive_results if result.start_ts]
has_outputs = bool(ordered_outputs)
is_archived = has_outputs or snapshot.status == Snapshot.StatusChoices.SEALED
snapshot_status = str(snapshot.status or "").lower()
status_label_by_state = {
"queued": ("queued", "info"),
"started": ("running", "warning"),
"paused": ("paused", "default"),
"sealed": ("archived", "success"),
}
if has_outputs and not is_archived:
status_label, status_color = ("partial", "warning")
elif has_outputs:
status_label, status_color = ("archived", "success")
else:
status_label, status_color = status_label_by_state.get(snapshot_status, ("not yet archived", "danger"))
context = {
"id": str(snapshot.id),
"snapshot_id": str(snapshot.id),
"progress_endpoint": progress_endpoint("snapshot", snapshot.id),
"progress_auto_expand": snapshot_status in {"queued", "started", "paused"},
"url": snapshot.url,
"archive_path": snapshot.archive_path_from_db,
"title": htmldecode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
"extension": snapshot.extension or "html",
"tags": snapshot.tags_str() or "untagged",
"size": printable_filesize(output_size) if output_size else "",
"status": status_label,
"status_color": status_color,
"snapshot_state": snapshot_status,
"has_outputs": has_outputs,
"snapshot_permissions": snapshot_permissions,
"snapshot_permissions_icon": {
"public": "👥",
"unlisted": "🔗",
"private": "🔒",
}.get(snapshot_permissions, "👥"),
"bookmarked_date": snapshot.bookmarked_date,
"downloaded_datestr": snapshot.downloaded_datestr,
"num_outputs": snapshot.num_outputs,
"num_failures": snapshot.num_failures,
"oldest_archive_date": ts_to_date_str(min(archive_dates) if archive_dates else None),
"warc_path": warc_path,
"archiveresults": [*non_compact_outputs, *compact_outputs],
"best_result": best_result,
"snapshot": snapshot, # Pass the snapshot object for template tags
"CONFIG": runtime_config,
"related_snapshots": related_snapshots,
"related_years": related_years,
"loose_items": loose_items,
"failed_items": failed_items,
"can_delete_outputs": bool(request.user.is_authenticated and request.user.is_active and request.user.is_superuser),
"title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in sorted(tags, key=lambda tag: tag.name)],
}
return render(template_name="core/snapshot.html", request=request, context=context)
def get(self, request, path):
snapshot = None

View File

@ -319,9 +319,9 @@
user-select: all;
}
.header-toggle {
line-height: 12px;
font-size: 70px;
margin-top: -13px;
line-height: 1;
font-size: 24px;
margin-top: 0;
margin-left: 4px;
}
@container snapshot-header (max-width: 900px) {
@ -1475,11 +1475,13 @@
</head>
<body>
<div id="main-frame-wrapper" class="full-page-wrapper" data-has-outputs="{{ has_outputs|yesno:'1,0' }}" data-snapshot-state="{{ snapshot_state }}">
{% if not STATIC_EXPORT %}
{% if snapshot_state == 'queued' or snapshot_state == 'started' or snapshot_state == 'paused' %}
<div id="snapshot-progress-wrapper">
{% include "progressmonitor/progress_monitor.html" with progress_endpoint=progress_endpoint progress_scope="snapshot" %}
</div>
{% endif %}
{% endif %}
{% if has_outputs %}
<iframe id="main-frame"
sandbox="allow-same-origin allow-top-navigation-by-user-activation allow-scripts allow-forms"
@ -1567,8 +1569,9 @@
<div class="header-nav">
<div class="header-col header-left" style="line-height: 58px; vertical-align: middle">
{% web_base_url as web_base %}
<a href="{% if web_base %}{{ web_base }}/public/{% else %}/{% endif %}" class="header-archivebox" title="Go to Public Index...">
<img src="{% if web_base %}{{ web_base }}/static/archive.png{% else %}{% static 'archive.png' %}{% endif %}" alt="Archive Icon">
{% static_export_root_url as static_root %}
<a href="{% if STATIC_EXPORT %}{{ static_root }}index.html{% elif web_base %}{{ web_base }}/public/{% else %}/{% endif %}" class="header-archivebox" title="Go to Public Index...">
<img src="{% if STATIC_EXPORT %}data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'&gt;&lt;rect x='8' y='5' width='48' height='54' rx='7' fill='%23fff' opacity='.95'/&gt;&lt;path d='M18 16h28M18 26h28M18 36h20M18 46h24' stroke='%23aa1e55' stroke-width='5' stroke-linecap='round'/&gt;&lt;/svg&gt;{% elif web_base %}{{ web_base }}/static/archive.png{% else %}{% static 'archive.png' %}{% endif %}" alt="Archive Icon">
ArchiveBox
</a>
</div>
@ -1614,34 +1617,44 @@
</div>
<div class="badge badge-info">
<span class="badge-label">Size</span>
{% if STATIC_EXPORT %}
<span class="badge-value">{{size}}</span>
{% else %}
<a class="badge-value" href="{% admin_base_url %}/admin/core/snapshot/{{snapshot_id|default:id}}/change/" title="Click to edit this Snapshot in the Admin UI">
{{size}}
</a>
{% endif %}
</div>
{% if not STATIC_EXPORT %}
<div class="badge badge-default">
<span class="badge-label">Manage</span>
<a class="badge-value" href="{% admin_base_url %}/admin/core/snapshot/{{snapshot_id|default:id}}/change/" title="Click to edit this Snapshot in the Admin UI">
<span class="badge-desktop-icon">✏️</span><span class="badge-mobile-text">Edit</span>
</a>
</div>
{% endif %}
<div class="badge badge-{{status_color}}">
<span class="badge-label">Status</span>
{% if STATIC_EXPORT %}
<span class="badge-value">{{status|upper}}</span>
{% else %}
<a class="badge-value" href="{% admin_base_url %}/admin/core/snapshot/?q={{snapshot_id|default:id}}" title="Click to see options to pull, re-snapshot, or delete this Snapshot">
{{status|upper}}
</a>
{% endif %}
</div>
</div>
<div class="header-mobile-badges">
<a class="header-mobile-badge" href="{% snapshot_base_url snapshot %}/?files=1" title="Browse all files for this snapshot">
<a class="header-mobile-badge" href="{% if STATIC_EXPORT %}{% snapshot_url snapshot 'index.jsonl' %}{% else %}{% snapshot_base_url snapshot %}/?files=1{% endif %}" title="Browse all files for this snapshot">
<span>{{num_outputs}} Files</span>
<span class="header-mobile-badge-separator">|</span>
<span>{{size}}</span>
</a>
<a class="header-mobile-badge" href="{% admin_base_url %}/admin/core/snapshot/{{snapshot_id|default:id}}/change/" title="Edit this snapshot in the Admin UI">
{% if not STATIC_EXPORT %}<a class="header-mobile-badge" href="{% admin_base_url %}/admin/core/snapshot/{{snapshot_id|default:id}}/change/" title="Edit this snapshot in the Admin UI">
<span>{{status|capfirst}}</span>
<span class="header-mobile-badge-separator">|</span>
<span>Edit</span>
</a>
</a>{% endif %}
</div>
{% if related_years %}
<div class="header-year-badges">
@ -1687,14 +1700,14 @@
</div>
</details>
{% else %}
<a class="header-date" href="{% web_base_url %}/{{archive_path}}/index.html" title="Date Added: {{bookmarked_date}} | First Archived: {{oldest_archive_date|default:downloaded_datestr}} | Last Checked: {{downloaded_datestr}} (UTC)">
<a class="header-date" href="{% snapshot_base_url snapshot %}/index.html" title="Date Added: {{bookmarked_date}} | First Archived: {{oldest_archive_date|default:downloaded_datestr}} | Last Checked: {{downloaded_datestr}} (UTC)">
<span class="header-date-label">Captures</span>
{{oldest_archive_date|default:downloaded_datestr|default:bookmarked_date|slice:":10"}}
</a>
{% endif %}
<br/>
<div class="external-links">
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Browse the full SNAP_DIR for this snapshot" target="_blank">📁 See all files...</a>
<a href="{% if STATIC_EXPORT %}{% snapshot_url snapshot 'index.jsonl' %}{% else %}{% snapshot_base_url snapshot %}/?files=1{% endif %}" title="Browse the full SNAP_DIR for this snapshot" target="_blank">📁 {% if STATIC_EXPORT %}Snapshot manifest{% else %}See all files...{% endif %}</a>
<span class="external-links-separator">|</span>
<a href="https://web.archive.org/web/{{url}}" title="Search for a copy of the URL saved in Archive.org" target="_blank" rel="noreferrer">🏛️ Archive.org</a>
<!--<a href="https://archive.md/{{url}}" title="Search for a copy of the URL saved in Archive.today" target="_blank" rel="noreferrer">Archive.today</a> &nbsp;|&nbsp; -->
@ -1714,7 +1727,7 @@
<div class="thumb-card{% if forloop.first %} selected-card{% endif %}" data-plugin-name="{{result.name|plugin_name}}"{% if preview_url %} data-preview-url="{{preview_url}}"{% endif %}{% if display_path %} data-output-path="{{display_path}}"{% endif %}>
<div class="thumb-body">
<div class="thumb-actions">
<a href="{% snapshot_url snapshot result.name %}/?files=1" data-no-preview="1" title="Open output folder" target="_blank" rel="noopener">📁</a>
{% if not STATIC_EXPORT %}<a href="{% snapshot_url snapshot result.name %}/?files=1" data-no-preview="1" title="Open output folder" target="_blank" rel="noopener">📁</a>{% endif %}
{% if display_path %}
<a href="{{display_url}}" data-no-preview="1" title="Download output file" download>⬇️</a>
{% endif %}
@ -1807,7 +1820,6 @@
</header>
{% if can_delete_outputs %}<input type="hidden" id="delete-output-csrf" value="{{csrf_token}}">{% endif %}
<script src="{% static 'jquery.min.js' %}" type="text/javascript"></script>
{% if can_delete_outputs %}{% include "includes/output_delete_controls.html" %}{% endif %}
<script>
@ -1932,9 +1944,7 @@
}
// un-sandbox iframes showing pdfs (required to display pdf viewer)
jQuery('iframe').map(function() {
attachPreviewFrameHandlers(this)
})
document.querySelectorAll('iframe').forEach((frame) => attachPreviewFrameHandlers(frame))
function getPreviewHashValueFromHref(href, normalize=false) {
if (href == './') {
@ -2058,8 +2068,8 @@
return false
}
jQuery('.selected-card').removeClass('selected-card')
jQuery(card).closest('.thumb-card').addClass('selected-card')
document.querySelectorAll('.selected-card').forEach((selected) => selected.classList.remove('selected-card'))
card.closest('.thumb-card').classList.add('selected-card')
const nextSrc = isSnapshotRootPreview(target) ? snapshotFilesUrl : target
const existingFrame = document.getElementById('main-frame')
@ -2158,8 +2168,8 @@
function hideSnapshotHeader() {
console.log('Collapsing Snapshot header...')
jQuery('.header-toggle').text('▸')
jQuery('.header-bottom').hide()
document.querySelectorAll('.header-toggle').forEach((toggle) => { toggle.textContent = '▸' })
document.querySelectorAll('.header-bottom').forEach((header) => { header.hidden = true })
try {
localStorage.setItem("archivebox-snapshot-header-visible", "false")
} catch (e) {
@ -2168,8 +2178,8 @@
}
function showSnapshotHeader() {
console.log('Expanding Snapshot header...')
jQuery('.header-toggle').text('▾')
jQuery('.header-bottom').show()
document.querySelectorAll('.header-toggle').forEach((toggle) => { toggle.textContent = '▾' })
document.querySelectorAll('.header-bottom').forEach((header) => { header.hidden = false })
try {
localStorage.setItem("archivebox-snapshot-header-visible", "true")
} catch (e) {
@ -2190,7 +2200,7 @@
}
function handleSnapshotHeaderToggle(event) {
event.preventDefault()
if (jQuery('.header-toggle').text().includes('▾')) {
if ([...document.querySelectorAll('.header-toggle')].some((toggle) => toggle.textContent.includes('▾'))) {
hideSnapshotHeader()
} else {
showSnapshotHeader()
@ -2199,7 +2209,7 @@
}
// Hide or show the header once when its title row or collapse icon is clicked.
jQuery('.header-toggle-trigger').on('click', handleSnapshotHeaderToggle)
document.querySelectorAll('.header-toggle-trigger').forEach((trigger) => trigger.addEventListener('click', handleSnapshotHeaderToggle))
// check URL for hash e.g. #git and load relevant preview
selectInitialPreview()

View File

@ -1,258 +1,129 @@
{% load static core_tags %}
{% load core_tags %}
<!DOCTYPE html>
<html lang="en">
<head>
<title>Archived Sites</title>
<meta charset="utf-8" name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg-main: #efefef;
--accent-1: #aa1e55;
--accent-2: #ffebeb;
--accent-3: #efefef;
--text-1: #1c1c1c;
--text-2: #eaeaea;
--text-main: #1a1a1a;
--font-main: "Gill Sans", Helvetica, sans-serif;
<head>
<title>Archived Sites</title>
<meta charset="utf-8" name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #f8fafc; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.static-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 20px; background: #aa1e55; color: #fff; }
.static-brand { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 700; text-decoration: none; }
.static-brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 7px; background: #fff; color: #aa1e55; font-size: 19px; }
.static-meta { color: rgba(255,255,255,.82); font-size: 12px; }
main { width: min(1500px, 100%); margin: 0 auto; padding: 18px; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
#static-search { width: min(560px, 100%); height: 36px; padding: 7px 11px; border: 1px solid #cbd5e1; border-radius: 7px; font: inherit; font-size: 13px; }
.count { color: #64748b; font-size: 12px; white-space: nowrap; }
.table-wrap { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
table { width: 100%; min-width: 980px; table-layout: fixed; border-collapse: collapse; }
th { padding: 9px 10px; border-bottom: 1px solid #e2e8f0; background: #f8fafc; color: #475569; font-size: 11px; letter-spacing: .04em; text-align: left; text-transform: uppercase; }
td { padding: 8px 10px; border-bottom: 1px solid #edf2f7; vertical-align: middle; }
tbody tr:hover { background: #f8fafc; }
tbody tr:last-child td { border-bottom: 0; }
.snapshot-time { width: 102px; white-space: nowrap; }
.snapshot-time a { display: inline-flex; flex-direction: column; color: #334155; line-height: 1.2; text-decoration: none; }
.snapshot-time small { color: #94a3b8; font-size: 11px; }
.snapshot-preview-cell { width: 116px; }
.snapshot-preview-cell a { display: inline-flex; width: 102px; height: 102px; align-items: center; justify-content: center; }
.snapshot-preview { display: block; width: 100px; height: 100px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; object-fit: cover; object-position: top; }
.snapshot-preview-spinner { display: grid; place-items: center; }
.snapshot-preview-spinner::after { width: 20px; height: 20px; border: 2px solid #cbd5e1; border-top-color: #aa1e55; border-radius: 50%; content: ""; animation: spin .8s linear infinite; }
.snapshot-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; margin-bottom: 2px; }
.snapshot-favicon-link { display: inline-flex; width: 22px; min-width: 22px; height: 22px; align-items: center; justify-content: center; }
.link-favicon { display: block; width: 18px; height: 18px; border-radius: 4px; object-fit: contain; }
.snapshot-title { overflow: hidden; color: #0f172a; font-size: 14px; font-weight: 650; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
.snapshot-url { display: block; margin-left: 30px; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
.snapshot-mobile-saved { display: none; }
.snapshot-tags-cell { width: 180px; }
.snapshot-tags { display: flex; flex-wrap: wrap; gap: 4px; max-height: 46px; overflow: hidden; }
.snapshot-tag { max-width: 150px; padding: 2px 7px; overflow: hidden; border: 1px solid #bfdbfe; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 11px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.snapshot-status-cell { width: 96px; }
.snapshot-status { display: inline-flex; min-width: 68px; justify-content: center; padding: 3px 8px; border-radius: 999px; background: #f1f5f9; color: #475569; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.status-sealed .snapshot-status, .status-succeeded .snapshot-status { background: #d1fae5; color: #047857; }
.status-started .snapshot-status { background: #dbeafe; color: #1d4ed8; }
.status-queued .snapshot-status, .status-backoff .snapshot-status { background: #fef3c7; color: #b45309; }
.status-failed .snapshot-status { background: #fee2e2; color: #b91c1c; }
.snapshot-files-cell { width: 220px; overflow: hidden; }
.snapshot-files-cell .files-icons { max-width: 200px; }
.snapshot-size-cell { width: 102px; white-space: nowrap; }
.snapshot-size-cell a { display: block; color: #334155; font-size: 12px; font-weight: 700; text-decoration: none; }
.snapshot-size-cell small, .empty-value { color: #94a3b8; font-size: 10px; }
footer { padding: 24px 16px; color: #64748b; font-size: 12px; text-align: center; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 700px) {
.static-header, .toolbar { align-items: stretch; flex-direction: column; }
main { padding: 10px; }
table { min-width: 0; }
th:nth-child(n+4), td:nth-child(n+4), th.snapshot-time, td.snapshot-time { display: none; }
.snapshot-preview-cell { width: 88px; }
.snapshot-preview-cell a { width: 76px; height: 76px; }
.snapshot-preview { width: 74px; height: 74px; }
.snapshot-mobile-saved { display: block; margin: 4px 0 0 30px; color: #64748b; font-size: 11px; }
}
</style>
<script>
function nextPublicSnapshotPreview(img) {
const fallbacks = (img.dataset.fallbacks || '').split(',').filter(Boolean)
if (fallbacks.length) {
img.src = fallbacks.shift()
img.dataset.fallbacks = fallbacks.join(',')
return
}
/* Dark Mode (WIP) */
/*
@media (prefers-color-scheme: dark) {
:root {
--accent-2: hsl(160, 100%, 96%);
--text-1: #eaeaea;
--text-2: #1a1a1a;
--bg-main: #101010;
}
#table-bookmarks_wrapper,
#table-bookmarks_wrapper img,
tbody td:nth-child(3),
tbody td:nth-child(3) span,
footer {
filter: invert(100%);
}
}*/
html, body {
width: 100%;
height: 100%;
font-size: 18px;
font-weight: 200;
text-align: center;
margin: 0px;
padding: 0px;
font-family: var(--font-main);
}
.header-top small {
font-weight: 200;
color: var(--accent-3);
}
.header-top {
width: 100%;
height: auto;
min-height: 40px;
margin: 0px;
text-align: center;
color: white;
font-size: calc(11px + 0.84vw);
font-weight: 200;
padding: 4px 4px;
border-bottom: 3px solid var(--accent-1);
background-color: var(--accent-1);
}
input[type=search] {
width: 22vw;
border-radius: 4px;
border: 1px solid #aeaeae;
padding: 3px 5px;
}
.nav > div {
min-height: 30px;
}
.header-top a {
text-decoration: none;
color: rgba(0,0,0,0.6);
}
.header-top a:hover {
text-decoration: none;
color: rgba(0,0,0,0.9);
}
.header-top .col-lg-4 {
text-align: center;
padding-top: 4px;
padding-bottom: 4px;
}
.header-archivebox img {
display: inline-block;
margin-right: 3px;
height: 30px;
margin-left: 12px;
margin-top: -4px;
margin-bottom: 2px;
}
.header-archivebox img:hover {
opacity: 0.5;
}
#table-bookmarks_length, #table-bookmarks_filter {
padding-top: 12px;
opacity: 0.8;
padding-left: 24px;
padding-right: 22px;
margin-bottom: -16px;
}
table {
padding: 6px;
width: 100%;
}
table thead th {
font-weight: 400;
}
table tr {
height: 35px;
}
tbody tr:nth-child(odd) {
background-color: var(--accent-2) !important;
}
table tr td {
white-space: nowrap;
overflow: hidden;
/*padding-bottom: 0.4em;*/
/*padding-top: 0.4em;*/
padding-left: 2px;
text-align: center;
}
table tr td a {
text-decoration: none;
}
table tr td img, table tr td object {
display: inline-block;
margin: auto;
height: 24px;
width: 24px;
padding: 0px;
padding-right: 5px;
vertical-align: middle;
margin-left: 4px;
}
#table-bookmarks {
width: 100%;
overflow-y: scroll;
table-layout: fixed;
}
.dataTables_wrapper {
background-color: #fafafa;
}
table tr a span[data-archived~=False] {
opacity: 0.4;
}
.files-spinner {
height: 15px;
width: auto;
opacity: 0.5;
vertical-align: -2px;
}
.in-progress {
display: none;
}
tr td a.favicon img {
padding-left: 6px;
padding-right: 12px;
vertical-align: -4px;
}
tr td a.title {
font-size: 1.4em;
text-decoration:none;
color:black;
}
tr td a.title small {
background-color: var(--accent-3);
border-radius: 4px;
float:right
}
input[type=search]::-webkit-search-cancel-button {
-webkit-appearance: searchfield-cancel-button;
}
.title-col {
text-align: left;
}
.title-col a {
color: black;
}
</style>
<link rel="stylesheet" href="{% static 'bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'jquery.dataTables.min.css' %}"/>
<script src="{% static 'jquery.min.js' %}"></script>
<script src="{% static 'jquery.dataTables.min.js' %}"></script>
<script>
document.addEventListener('error', function(e) {
e.target.style.opacity = 0;
}, true)
jQuery(document).ready(function() {
jQuery('#table-bookmarks').DataTable({
stateSave: true, // save state (filtered input, number of entries shown, etc) in localStorage
dom: '<lf<t>ip>', // how to show the table and its helpers (filter, etc) in the DOM
order: [[0, 'desc']],
iDisplayLength: 100,
});
});
</script>
</head>
<body>
<header>
<div class="header-top container-fluid">
<div class="row nav">
<div class="col-sm-2">
<a href="/" class="header-archivebox">
<img src="{% static 'archive.png' %}" alt="Logo"/>
ArchiveBox: Index
</a>
</div>
<div class="col-sm-10" style="text-align: right">
<a href="/add/">Add Links</a> &nbsp; | &nbsp;
<a href="/admin/core/snapshot/">Admin</a> &nbsp; | &nbsp;
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki">Docs</a>
</div>
</div>
</div>
</header>
<table id="table-bookmarks">
<thead>
<tr>
<th class="snapshot-time">Saved</th>
<th class="snapshot-preview-cell">Preview</th>
<th>Snapshot ({{num_links}})</th>
<th class="snapshot-tags-cell">Tags</th>
<th class="snapshot-status-cell">Status</th>
<th class="snapshot-files-cell">Files</th>
<th class="snapshot-size-cell">Size</th>
</tr>
</thead>
<tbody>
{% for link in links %}
{% snapshot_index_row link %}
{% endfor %}
</tbody>
</table>
<footer>
<br/>
<center>
<small>
Archive created using <a href="https://github.com/ArchiveBox/ArchiveBox" title="Github">ArchiveBox</a>
version <a href="https://github.com/ArchiveBox/ArchiveBox/releases/tag/v{{version}}" title="View source code and release info">v{{version}}</a> &nbsp; | &nbsp;
Download index as <a href="index.json" title="JSON summary of archived links.">JSON</a>
<br/><br/>
{{FOOTER_INFO}}
</small>
</center>
<br/>
</footer>
</body>
img.style.opacity = 0
}
</script>
</head>
<body>
<header class="static-header">
<a class="static-brand" href="./index.html"><span class="static-brand-mark"></span> ArchiveBox</a>
<span class="static-meta">Portable static archive · updated {{ time_updated }}</span>
</header>
<main>
<div class="toolbar">
<input id="static-search" type="search" placeholder="Filter title, URL, tag, or status…" autocomplete="off">
<span class="count"><span id="visible-count">{{ num_links }}</span> of {{ num_links }} snapshots</span>
</div>
<div class="table-wrap">
<table id="table-bookmarks">
<thead>
<tr>
<th class="snapshot-time">Saved</th>
<th class="snapshot-preview-cell">Preview</th>
<th>Snapshot ({{ num_links }})</th>
<th class="snapshot-tags-cell">Tags</th>
<th class="snapshot-status-cell">Status</th>
<th class="snapshot-files-cell">Files</th>
<th class="snapshot-size-cell">Size</th>
</tr>
</thead>
<tbody>
{% for link in links %}{% snapshot_index_row link %}{% endfor %}
</tbody>
</table>
</div>
</main>
<footer>
Generated by <a href="https://github.com/ArchiveBox/ArchiveBox">ArchiveBox v{{ version }}</a>.
<a href="./index.json">Download JSON metadata</a>.<br><br>{{ FOOTER_INFO }}
</footer>
<script>
(() => {
const search = document.getElementById('static-search')
const rows = [...document.querySelectorAll('#table-bookmarks tbody tr')]
const count = document.getElementById('visible-count')
search.addEventListener('input', () => {
const query = search.value.trim().toLowerCase()
let visible = 0
rows.forEach((row) => {
const show = !query || row.textContent.toLowerCase().includes(query)
row.hidden = !show
if (show) visible += 1
})
count.textContent = visible
})
})()
</script>
</body>
</html>

View File

@ -13,6 +13,7 @@ import hashlib
import sqlite3
from pathlib import Path
from datetime import datetime, timezone
from urllib.parse import urlparse
from archivebox.tests.conftest import cli_env, run_archivebox_cmd
from archivebox.uuid_compat import uuid7
@ -1272,6 +1273,24 @@ def filesystem_manifest(root: Path) -> dict[str, tuple[str, str | int]]:
return manifest
def current_snapshot_dir(data_dir: Path, db_path: Path, timestamp: str) -> Path:
"""Resolve a migrated snapshot's canonical archive/users/... directory."""
with sqlite3.connect(db_path) as connection:
username, bookmarked_at, snapshot_id, url = connection.execute(
"""
SELECT u.username, s.bookmarked_at, s.id, s.url
FROM core_snapshot s
JOIN crawls_crawl c ON c.id = s.crawl_id
JOIN auth_user u ON u.id = c.created_by_id
WHERE s.timestamp = ?
""",
(timestamp,),
).fetchone()
date_bucket = datetime.fromisoformat(bookmarked_at).strftime("%Y%m%d")
domain = urlparse(url).hostname or "unknown"
return data_dir / "archive" / "users" / username / "snapshots" / date_bucket / domain / snapshot_id
def verify_snapshot_count(db_path: Path, expected: int) -> tuple[bool, str]:
"""Verify the number of snapshots in the database."""
conn = sqlite3.connect(str(db_path))

View File

@ -4,6 +4,7 @@ Verify list emits snapshot JSONL and applies the documented filters.
"""
import json
from pathlib import Path
import pytest
from django.contrib.auth import get_user_model
@ -19,6 +20,40 @@ from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
def test_static_exports_use_filesystem_paths_not_live_django_routes(snapshot):
from archivebox.config import CONSTANTS
from archivebox.core.models import ArchiveResult
snapshot_dir = Path(snapshot.output_dir)
screenshot_dir = snapshot_dir / "screenshot"
screenshot_dir.mkdir(parents=True, exist_ok=True)
screenshot_file = screenshot_dir / "screenshot.png"
screenshot_file.write_bytes(b"real screenshot")
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="screenshot",
hook_name="on_Snapshot__50_screenshot.py",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_str="screenshot.png",
output_files={"screenshot.png": {"size": screenshot_file.stat().st_size}},
output_size=screenshot_file.stat().st_size,
)
static_path = snapshot_dir.relative_to(CONSTANTS.DATA_DIR).as_posix()
queryset = Snapshot.objects.filter(pk=snapshot.pk).prefetch_related("tags")
html = queryset.to_html(with_headers=True)
[record] = json.loads(queryset.to_json(with_headers=False))
assert f"./{static_path}/index.html" in html
assert f"./{static_path}/screenshot/screenshot.png" in html
assert f"./{static_path}/index.jsonl" in html
assert f"/snapshot/{snapshot.id.hex}" not in html
assert "/web/" not in html
assert "/static/" not in html
assert record["archive_path"] == static_path
assert record["archive_url"] == f"./{static_path}/index.html"
def test_streaming_json_matches_snapshot_serializer(initialized_archive):
from archivebox.crawls.models import Crawl

View File

@ -1576,7 +1576,7 @@ class TestRecoverOrchestratorState:
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.fs_version == Snapshot._fs_current_version()
assert snapshot.output_dir.joinpath("index.html").read_text(encoding="utf-8") == "legacy archive"
assert legacy_dir.is_symlink()
assert not legacy_dir.exists()
@pytest.mark.django_db(transaction=True)
def test_run_due_snapshot_migrates_filesystem_after_sealed_parent_reconciliation(self):
@ -1618,7 +1618,7 @@ class TestRecoverOrchestratorState:
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.fs_version == Snapshot._fs_current_version()
assert snapshot.output_dir.joinpath("index.html").read_text(encoding="utf-8") == "legacy archive"
assert legacy_dir.is_symlink()
assert not legacy_dir.exists()
@pytest.mark.django_db(transaction=True)
def test_run_due_snapshot_runs_queued_plugin_after_fs_migration(self):

View File

@ -1,6 +1,8 @@
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from archivebox.tests.conftest import cli_env, run_archivebox_cmd
import pytest
@ -120,12 +122,12 @@ def test_update_imports_orphaned_snapshots(tmp_path, initialized_archive):
assert update_process.returncode == 0, update_process.stderr
with use_archivebox_db(tmp_path):
row = Snapshot.objects.values_list("url", "fs_version").get()
migrated_snapshot = Snapshot.objects.get()
row = (migrated_snapshot.url, migrated_snapshot.fs_version)
migrated_dir = Path(migrated_snapshot.output_dir)
assert row == ("https://example.com", Snapshot._fs_current_version())
assert legacy_dir.is_symlink()
migrated_dir = legacy_dir.resolve()
assert not legacy_dir.exists()
assert migrated_dir.exists()
assert '{"type":"Process","id":"incomplete"}\n' in (migrated_dir / "index.jsonl").read_text()
assert (migrated_dir / "singlefile.html").exists()
@ -201,7 +203,7 @@ def test_update_migrates_every_declared_filesystem_version(tmp_path, initialized
assert {path: migrated_tree.get(path) for path in original_tree} == original_tree
if legacy_layout:
assert source_dir.is_symlink()
assert not source_dir.exists()
update_process = run_archivebox_cmd(["update", "--migrate-only"], env=env, timeout=90)
assert update_process.returncode == 0, f"Idempotency update failed: {update_process.stderr}"

View File

@ -6,6 +6,7 @@ import sqlite3
from .migrations_helpers import (
SCHEMA_0_4,
create_data_dir_structure,
current_snapshot_dir,
filesystem_manifest,
run_archivebox_migration_cmd,
seed_0_4_data,
@ -94,8 +95,8 @@ def test_oldest_django_collection_migrates_end_to_end_without_data_loss(tmp_path
for timestamp, expected_tree in original_trees.items():
legacy_dir = tmp_path / "archive" / timestamp
assert legacy_dir.is_symlink()
migrated_tree = filesystem_manifest(legacy_dir.resolve())
assert not legacy_dir.exists()
migrated_tree = filesystem_manifest(current_snapshot_dir(tmp_path, db_path, timestamp))
assert {path: migrated_tree.get(path) for path in expected_tree} == expected_tree
with sqlite3.connect(db_path) as connection:

View File

@ -21,6 +21,7 @@ import pytest
from .migrations_helpers import (
SCHEMA_0_7,
SCHEMA_0_8,
current_snapshot_dir,
filesystem_manifest,
seed_0_8_data,
seed_0_7_data,
@ -800,7 +801,7 @@ def test_update_preserves_legacy_folder_timestamp_over_index_float_variant(tmp_p
conn.close()
assert row == (timestamp,)
assert (work_dir / "archive" / timestamp).is_symlink()
assert not (work_dir / "archive" / timestamp).exists()
assert not (work_dir / "archive" / f"{timestamp}.0").exists()
assert not (work_dir / "invalid").exists()
@ -842,8 +843,8 @@ def test_update_preserves_distinct_legacy_dirs_with_integer_and_float_timestamps
conn.close()
assert rows == [("1508259732",), ("1508259732.0",)]
assert (work_dir / "archive" / "1508259732").is_symlink()
assert (work_dir / "archive" / "1508259732.0").is_symlink()
assert not (work_dir / "archive" / "1508259732").exists()
assert not (work_dir / "archive" / "1508259732.0").exists()
assert not (work_dir / "invalid").exists()
@ -988,8 +989,8 @@ def test_07_filesystem_hop_preserves_complete_output_tree(tmp_path):
for timestamp, expected_tree in original_trees.items():
legacy_dir = tmp_path / "archive" / timestamp
assert legacy_dir.is_symlink()
migrated_tree = filesystem_manifest(legacy_dir.resolve())
assert not legacy_dir.exists()
migrated_tree = filesystem_manifest(current_snapshot_dir(tmp_path, db_path, timestamp))
assert {path: migrated_tree.get(path) for path in expected_tree} == expected_tree
assert (destination / "preexisting-output.bin").read_bytes() == b"destination-only output"
@ -1062,7 +1063,10 @@ def test_each_declared_filesystem_hop_preserves_outputs(migration_08_data, fs_ve
result = run_archivebox_migration_cmd(work_dir, ["update", "--migrate-only"], timeout=180)
assert result.returncode == 0, result.stderr
migrated_dir = source_dir.resolve()
migrated_dir = current_snapshot_dir(work_dir, db_path, snapshot["timestamp"])
assert {path: filesystem_manifest(migrated_dir).get(path) for path in expected_tree} == expected_tree
if fs_version in ("0.7.0", "0.8.0", "0.8.5"):
assert not source_dir.exists()
assert not source_dir.is_symlink()
with sqlite3.connect(db_path) as connection:
assert connection.execute("SELECT fs_version FROM core_snapshot WHERE id = ?", (snapshot["id"],)).fetchone() == ("0.9.4",)

View File

@ -1,6 +1,8 @@
"""Snapshot model and admin UI tests."""
import json
import os
import re
import shutil
import warnings
from pathlib import Path
@ -21,6 +23,16 @@ pytestmark = pytest.mark.django_db(transaction=True)
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_current_snapshot_layout_has_no_top_level_timestamp_projection(snapshot):
from archivebox.config import CONSTANTS
legacy_path = CONSTANTS.ARCHIVE_DIR / snapshot.timestamp
assert Path(snapshot.output_dir).is_relative_to(CONSTANTS.ARCHIVE_DIR / "users")
assert not legacy_path.exists()
assert not legacy_path.is_symlink()
@pytest.fixture
def real_hash_projection(snapshot, cached_abxpkg_lib_dir):
snapshot.output_dir.mkdir(parents=True, exist_ok=True)
@ -743,7 +755,61 @@ class TestSnapshotProgressStats:
assert "wrapper.style.height = `${contentHeight}px`" in rendered
assert 'class="header-toggle header-toggle-trigger"' not in rendered
assert "event.preventDefault()" in rendered
assert rendered.count(".on('click', handleSnapshotHeaderToggle)") == 1
assert rendered.count("addEventListener('click', handleSnapshotHeaderToggle)") == 1
def test_static_snapshot_detail_uses_same_output_cards_with_relative_files(self, snapshot):
from archivebox.config import CONSTANTS
from archivebox.core.models import ArchiveResult
from archivebox.core.views import SnapshotView
output_dir = Path(snapshot.output_dir)
singlefile_dir = output_dir / "singlefile"
singlefile_dir.mkdir(parents=True, exist_ok=True)
output_file = singlefile_dir / "singlefile.html"
output_file.write_text("<html><body>real static output</body></html>", encoding="utf-8")
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="singlefile",
hook_name="on_Snapshot__50_singlefile.py",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_str="singlefile.html",
output_files={"singlefile.html": {"size": output_file.stat().st_size}},
output_size=output_file.stat().st_size,
)
favicon_dir = output_dir / "favicon"
favicon_dir.mkdir(parents=True, exist_ok=True)
favicon_file = favicon_dir / "favicon.ico"
favicon_file.write_bytes(b"real favicon")
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="favicon",
hook_name="on_Snapshot__50_favicon.py",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_str="favicon.ico",
output_files={"favicon.ico": {"size": favicon_file.stat().st_size}},
output_size=favicon_file.stat().st_size,
)
request = RequestFactory().get(f"/{snapshot.url_path}/index.html", HTTP_HOST=ADMIN_TEST_HOST)
request.user = AnonymousUser()
live_html = SnapshotView.render_live_index(request, snapshot).content.decode()
snapshot.write_html_details()
snapshot.write_json_details()
static_html = (output_dir / "index.html").read_text(encoding="utf-8")
static_json = json.loads((output_dir / "index.json").read_text(encoding="utf-8"))
assert re.findall(r'data-plugin-name="([^"]+)"', static_html) == re.findall(r'data-plugin-name="([^"]+)"', live_html)
assert 'data-plugin-name="singlefile"' in static_html
assert 'href="./singlefile/singlefile.html"' in static_html
assert 'data-default-src="./singlefile/singlefile.html"' in static_html
assert 'src="./favicon/favicon.ico"' in static_html
root_href = os.path.relpath(CONSTANTS.DATA_DIR, start=output_dir).replace(os.sep, "/")
assert f'href="{root_href}/index.html" class="header-archivebox"' in static_html
assert f"/snapshot/{snapshot.id.hex}" not in static_html
assert "/static/jquery.min.js" not in static_html
assert static_json["archive_path"].startswith("archive/users/")
assert static_json["archive_url"] == f"./{static_json['archive_path']}/index.html"
def test_compact_output_cards_pack_into_dense_grid_rows(self):
template = (REPO_ROOT / "archivebox" / "templates" / "core" / "snapshot.html").read_text()

View File

@ -1282,8 +1282,10 @@ class TestUrlRouting:
assert ">Git<" not in live_html
static_html = Path(snapshot.output_dir, "index.html").read_text(encoding="utf-8", errors="ignore")
assert f"http://{snapshot_host}/" in static_html
assert f"http://{web_host}/static/archive.png" in static_html
assert f"http://{snapshot_host}/" not in static_html
assert f"http://{web_host}/static/archive.png" not in static_html
assert "data:image/svg+xml" in static_html
assert 'href="./' in static_html
assert "?preview=1" in static_html
assert "function createMainFrame(previousFrame)" in static_html
assert "function activateCardPreview(card, link, updateHash=true)" in static_html

View File

@ -4,12 +4,15 @@ import hashlib
import html
import json
import os
import subprocess
import struct
import sys
import tomllib
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
SOURCE_BASE_URL = "https://github.com/ArchiveBox/ArchiveBox/blob/dev/"
REPO_DIR = Path(__file__).resolve().parents[1]
CAPTURE_PROFILES = {
"desktop": (1600, 1000),
"tablet": (1024, 1366),
@ -55,6 +58,24 @@ REQUIRED_VIEW_NAMES = {
}
def build_provenance() -> dict[str, str]:
version = tomllib.loads((REPO_DIR / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
revision = os.environ.get("GITHUB_SHA", "").strip()
if not revision:
revision = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=REPO_DIR,
check=True,
capture_output=True,
text=True,
).stdout.strip()
return {
"version": version,
"revision": revision,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
def validate_navigation(metadata_path: Path, expected_path: str) -> None:
navigation = json.loads(metadata_path.read_text(encoding="utf-8"))
navigation = navigation.get("checks", navigation)
@ -96,6 +117,8 @@ def append_manifest(manifest_path: Path, screenshot_path: Path) -> None:
def build_galleries(manifest_path: Path, markdown_path: Path, html_path: Path) -> None:
provenance = build_provenance()
source_base_url = f"https://github.com/ArchiveBox/ArchiveBox/blob/{provenance['revision']}/"
captures = [json.loads(line) for line in manifest_path.read_text(encoding="utf-8").splitlines() if line]
allow_partial = os.environ.get("UI_SCREENSHOT_ALLOW_PARTIAL") == "1"
grouped_captures: list[dict[str, object]] = []
@ -155,7 +178,7 @@ def build_galleries(manifest_path: Path, markdown_path: Path, html_path: Path) -
route = parsed_url.path or "/"
if parsed_url.fragment:
route = f"{route}#{parsed_url.fragment}"
source_url = f"{SOURCE_BASE_URL}{capture['source']}"
source_url = f"{source_base_url}{capture['source']}"
markdown_cells = []
html_figures = []
for profile, (width, height) in CAPTURE_PROFILES.items():
@ -211,6 +234,7 @@ def build_galleries(manifest_path: Path, markdown_path: Path, html_path: Path) -
"These desktop, tablet, and mobile screenshots cover ArchiveBox's major public and authenticated UI views. "
"Raw API endpoints, API documentation, health-check, and error routes are intentionally excluded."
),
f"Generated from ArchiveBox `{provenance['version']}` at revision `{provenance['revision']}`.",
"",
"\n\n".join(markdown_sections),
"",
@ -229,12 +253,33 @@ def build_galleries(manifest_path: Path, markdown_path: Path, html_path: Path) -
"border:1px solid #cbd5e1;border-radius:8px;background:white;box-shadow:0 8px 24px #0f172a18}code{overflow-wrap:anywhere}"
"@media(max-width:900px){.shots{grid-template-columns:1fr}body{padding:12px}}"
'</style></head><body><header><p><a href="../">← ArchiveBox</a></p><h1>ArchiveBox UI Screenshots</h1>'
"<p>Generated from the current <code>dev</code> UI at desktop, tablet, and mobile viewports.</p></header><main>"
f"<p>Generated from ArchiveBox <code>{html.escape(provenance['version'])}</code> at "
f'<a href="https://github.com/ArchiveBox/ArchiveBox/commit/{html.escape(provenance["revision"])}">'
f"<code>{html.escape(provenance['revision'][:12])}</code></a> on "
f'<time datetime="{html.escape(provenance["generated_at"])}">{html.escape(provenance["generated_at"])}</time>. '
"Desktop, tablet, and mobile viewports are captured from the same build.</p></header><main>"
+ "".join(html_sections)
+ "</main></body></html>\n",
encoding="utf-8",
)
file_hashes = {
capture["filename"]: hashlib.sha256((html_path.parent / capture["filename"]).read_bytes()).hexdigest() for capture in captures
}
(html_path.parent / "build.json").write_text(
json.dumps(
{
**provenance,
"capture_count": len(captures),
"files": file_hashes,
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
if not allow_partial:
expected_filenames = {capture["filename"] for capture in captures}
for screenshot_dir in (markdown_path.parent / "screenshots", html_path.parent):

View File

@ -5,7 +5,7 @@ Current ArchiveBox collections cannot be merged safely by copying their `archive
The workflow below is retained for **legacy collections whose real Snapshot directories are `archive/<timestamp>/`**. `archivebox update` can import those legacy directories into a fresh index.
> [!WARNING]
> Back up every collection before merging. Confirm that the source entries are real legacy timestamp directories, not compatibility symlinks into `archive/users/...`, and inspect path conflicts instead of allowing one collection to overwrite another.
> Back up every collection before merging. Confirm that the source entries are real legacy timestamp directories containing data, and inspect path conflicts instead of allowing one collection to overwrite another.
1. Upgrade both old collections to the most recent ArchiveBox version (following instructions above)
```bash

View File

@ -56,7 +56,11 @@ location / {
Make sure you're not running any content as CGI or PHP, you only want to serve static files!
Legacy timestamp URLs remain available through compatibility symlinks, for example: `https://demo.archivebox.io/archive/1493350273/wget/en.wikipedia.org/wiki/Dining_philosophers_problem.html`
The generated links are relative, so the export works at a domain root or a project subpath such as GitHub Pages. Snapshot pages and outputs remain under their real filesystem paths, for example:
`archive/users/alice/snapshots/20260829/example.com/SNAPSHOT_UUID/index.html`
ArchiveBox does not create top-level timestamp symlinks for current snapshots. Run `archivebox update --migrate-only` to move real legacy `archive/<timestamp>/` directories into the user-scoped layout and remove obsolete timestamp projections.
<br/>
@ -71,7 +75,7 @@ Legacy timestamp URLs remain available through compatibility symlinks, for examp
Make sure you understand the dangers of [hosting untrusted HTML/JS/CSS](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy). The default `SERVER_SECURITY_MODE=auto` uses isolated subdomains with full replay on `*.localhost`, and a one-domain no-JS replay policy on ordinary public or LAN hostnames. Choose `safe-subdomains-fullreplay` only when wildcard DNS and TLS for `*.archive.example.com` are configured; it separates the admin, web, and API control planes from replay content and gives each Snapshot its own replay subdomain.
Do not serve ArchiveBox from a shared subdirectory such as `myapps.example.com/archivebox/`; it cannot provide the required origin isolation. If you do not need JavaScript-capable replay, you can also disable the relevant extractors with `WGET_ENABLED=False` and `DOM_ENABLED=False`.
Do not serve the authenticated ArchiveBox application from a shared subdirectory such as `myapps.example.com/archivebox/`; it cannot provide the required origin isolation. A standalone static export can be hosted at a project subpath because it contains no authenticated control plane. If you do not need JavaScript-capable replay, you can also disable the relevant extractors with `WGET_ENABLED=False` and `DOM_ENABLED=False`.
More info:
- https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview

View File

@ -53,7 +53,7 @@ The same command is used for initializing a new archive and upgrading an existin
There are three main areas on disk that ArchiveBox modifies during upgrades:
- `index.sqlite3` contains the SQLite3 DB index that gets upgraded automatically by Django based on the changes in [`archivebox/core/models.py`](https://github.com/ArchiveBox/ArchiveBox/blob/dev/archivebox/core/models.py).
- `archive/users/<user>/snapshots/<date>/<domain>/<uuid>/index.jsonl` stores per-Snapshot metadata alongside plugin-namespaced output. `archivebox update --migrate-only` may rewrite metadata, migrate older layouts, and maintain legacy timestamp compatibility symlinks.
- `archive/users/<user>/snapshots/<date>/<domain>/<uuid>/index.jsonl` stores per-Snapshot metadata alongside plugin-namespaced output. `archivebox update --migrate-only` may rewrite metadata, migrate older layouts, and remove obsolete timestamp projections after verified migration.
- Snapshot output directories and plugin paths can move as filesystem schemas evolve, so the entire `archive/` tree must be backed up with the database.
`ArchiveBox.conf` is migrated through the normal config loader/writer when options are renamed or normalized. Back it up with the rest of the collection and review release notes for config changes.

View File

@ -1396,14 +1396,6 @@ Bases: {py:obj}`archivebox.base_models.models.ModelWithDeleteAfter.Meta`, {py:ob
````
````{py:method} ensure_legacy_archive_symlink() -> None
:canonical: archivebox.core.models.Snapshot.ensure_legacy_archive_symlink
```{autodoc2-docstring} archivebox.core.models.Snapshot.ensure_legacy_archive_symlink
```
````
````{py:method} ensure_crawl_symlink(*, crawl_dir: pathlib.Path | None = None, snapshot_dir: pathlib.Path | None = None) -> None
:canonical: archivebox.core.models.Snapshot.ensure_crawl_symlink