diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py
index 8b4b81dd..f2553964 100644
--- a/archivebox/core/admin_archiveresults.py
+++ b/archivebox/core/admin_archiveresults.py
@@ -10,9 +10,12 @@ from pathlib import Path
from urllib.parse import quote
from django.contrib import admin
-from django.core.exceptions import ValidationError
+from django.contrib.admin.actions import delete_selected
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.core.exceptions import PermissionDenied, SuspiciousOperation, ValidationError
from django.db.models import Count, Min, Prefetch, Q, Subquery, TextField, Window
from django.db.models.functions import Cast
+from django.shortcuts import redirect
from django.urls import resolve, reverse
from django.utils import timezone
from django.utils.html import format_html
@@ -543,10 +546,44 @@ class ArchiveResultAdmin(BaseModelAdmin):
def changelist_view(self, request, extra_context=None):
self.request = request
+ selected = request.GET.getlist(ACTION_CHECKBOX_NAME)
+ if request.method == "GET" and request.GET.get("action") == "delete_selected" and selected:
+ if not request.user.is_superuser:
+ raise PermissionDenied
+ if len(selected) > 100:
+ raise SuspiciousOperation("Too many ArchiveResults selected for deletion")
+ try:
+ queryset = self.get_queryset(request).filter(pk__in=selected)
+ if not queryset.exists():
+ snapshot = Snapshot.objects.only("id").filter(pk=request.GET.get("snapshot")).first()
+ return redirect(build_snapshot_url(str(snapshot.id), "index.html", request=request) if snapshot else request.path)
+ except (ValidationError, ValueError):
+ return redirect(request.path)
+ return delete_selected(self, request, queryset)
+ handoff_snapshot = (
+ request.GET.get("snapshot") if request.method == "POST" and request.GET.get("action") == "delete_selected" else None
+ )
+ if handoff_snapshot:
+ request.GET = request.GET.copy()
+ request.GET.clear()
+ request.META["QUERY_STRING"] = ""
+ try:
+ handoff_snapshot = Snapshot.objects.only("id").filter(pk=handoff_snapshot).first()
+ except (ValidationError, ValueError):
+ handoff_snapshot = None
saved_list_per_page = self.list_per_page
self.list_per_page = request.archivebox_config.SNAPSHOTS_PER_PAGE
try:
- return super().changelist_view(request, extra_context)
+ response = super().changelist_view(request, extra_context)
+ if (
+ handoff_snapshot
+ and response.status_code in (301, 302)
+ and not ArchiveResult.objects.filter(
+ pk__in=request.POST.getlist(ACTION_CHECKBOX_NAME),
+ ).exists()
+ ):
+ return redirect(build_snapshot_url(str(handoff_snapshot.id), "index.html", request=request))
+ return response
finally:
self.list_per_page = saved_list_per_page
diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py
index fb8a87ca..b7178e88 100644
--- a/archivebox/core/middleware.py
+++ b/archivebox/core/middleware.py
@@ -39,8 +39,8 @@ ADMIN_LOGIN_HINT_COOKIE = "archivebox_admin_logged_in"
def _admin_login_hint_cookie_domain(config) -> str | None:
"""Resolve the parent domain to scope the cross-subdomain login hint.
- NOTE: this cookie carries only the single bit "user is logged in on
- admin somewhere"; it MUST NOT be confused with the session cookie,
+ NOTE: this cookie carries only the single bit "a superuser is logged in
+ on admin somewhere"; it MUST NOT be confused with the session cookie,
which stays admin-host-scoped (see core/settings.py
SESSION_COOKIE_DOMAIN comment — admin/web is a security boundary).
@@ -88,6 +88,10 @@ def AdminCookieIsolationMiddleware(get_response):
def middleware(request):
response = get_response(request)
+ if request.path == "/admin" or request.path.startswith("/admin/"):
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
+
config = request.__dict__.get("archivebox_config")
if config is None or config.SERVER_SECURITY_MODE == "auto":
from archivebox.config.common import get_request_config
@@ -197,6 +201,21 @@ def ServerSecurityModeMiddleware(get_response):
config = get_request_config(request, resolve_plugins=False)
+ if config.USES_SUBDOMAIN_ROUTING and config.BASE_URL and request.method.upper() not in allowed_methods:
+ request_host, _request_port = split_host_port((request.get_host() or "").lower())
+ control_hosts = {
+ split_host_port(host)[0]
+ for host in (
+ get_base_host(config=config),
+ get_admin_host(config=config),
+ get_api_host(config=config),
+ get_web_host(config=config),
+ )
+ if host
+ }
+ if request_host not in control_hosts:
+ return HttpResponseForbidden("ArchiveBox is running with the control plane disabled on this host.")
+
if config.CONTROL_PLANE_ENABLED:
return get_response(request)
@@ -319,7 +338,12 @@ def HostRoutingMiddleware(get_response):
return redirect(target)
response = get_response(request)
hint_cookie_domain = _admin_login_hint_cookie_domain(config)
- if request.user.is_authenticated and not request.path.startswith("/admin/logout"):
+ if (
+ request.user.is_authenticated
+ and request.user.is_active
+ and request.user.is_superuser
+ and not request.path.startswith("/admin/logout")
+ ):
response.set_cookie(
ADMIN_LOGIN_HINT_COOKIE,
"1",
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index b03cc527..139a8d95 100644
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -3594,6 +3594,14 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"warc/",
)
user = getattr(request, "user", None)
+ can_delete_outputs = bool(
+ static_export_dir is None
+ and request is not None
+ and (
+ (user and user.is_authenticated and user.is_active and user.is_superuser)
+ or request.COOKIES.get("archivebox_admin_logged_in") == "1"
+ ),
+ )
tag_widget = TagEditorWidget()
return {
"id": str(self.id),
@@ -3626,7 +3634,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"related_years": related_years,
"loose_items": loose_items,
"failed_items": failed_items,
- "can_delete_outputs": bool(user and user.is_authenticated and user.is_active and user.is_superuser),
+ "can_delete_outputs": can_delete_outputs,
"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,
diff --git a/archivebox/templates/core/snapshot.html b/archivebox/templates/core/snapshot.html
index 1bc558b0..8e88ac20 100644
--- a/archivebox/templates/core/snapshot.html
+++ b/archivebox/templates/core/snapshot.html
@@ -1732,7 +1732,7 @@
⬇️
{% endif %}
{% if can_delete_outputs and result.result %}
-
+
{% endif %}
{% if display_path %}
@@ -1819,7 +1819,6 @@
- {% if can_delete_outputs %}{% endif %}
{% if can_delete_outputs %}{% include "includes/output_delete_controls.html" %}{% endif %}