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 %}