Files
J621/backend/apps/library/tools.py
T
JakeBreath f86eccf9a3 Security fixes: SSRF, staff role escalation, SPA-only gating, throttling, encrypted keys
Findings from the audit (50-check harness across guest/user/uploader/staff/
admin) and their fixes:

- SSRF: 'Download to Library' and the staged-upload resolve path fetched
  any http(s) URL. services.validate_remote_url now enforces the e621
  media allowlist and open_remote re-validates every redirect hop; the
  download-task create endpoint and the guest proxy use them, so internal
  addresses (127.0.0.1, LAN, metadata) are rejected with 400.
- Privilege escalation: staff could promote users to staff and demote
  other staff. Role changes across the staff boundary now require an
  admin, matching the account-deletion rules; the Users page hides what
  the backend would refuse.
- SPA-only gating: /api/storage/ and /api/duplicates/* were readable by
  any authenticated account (absolute paths, duplicate groups) while the
  SPA only shows them to uploaders. They now require CanUpload.
- Throttling (REST_FRAMEWORK, env-overridable, counted in Redis):
  anon 120/min, user 600/min, login 5/min, register 20/hour, guest e621
  proxy 60/hour. Login now goes through a throttled view.
- e621 API keys are encrypted at rest with a Fernet key derived from
  SECRET_KEY (apps/accounts/crypto.py); a data migration encrypts existing
  rows and the column widens first. Reads decrypt transparently, legacy
  plaintext still works, and a changed SECRET_KEY reads as 'not
  configured' instead of leaking. Rotating SECRET_KEY now invalidates
  stored keys as well as signed media URLs.
- Hardening: the server refuses to start with DEBUG=False while SECRET_KEY
  is still the development default.

Verified: corrected harness 50/50 (guest visibility, IDOR, signed-URL
tamper/expiry, staged-upload/similarity privacy, role matrix, SSRF),
login throttles at the 6th attempt with 429, anon polling unaffected, the
guest proxy still reaches allowlisted hosts, live e621 auth works with the
decrypted key, and DB rows hold only ciphertext.
2026-09-18 00:21:14 -05:00

457 lines
14 KiB
Python

"""Library tooling: duplicates, deletion and storage overview."""
import logging
import shutil
from pathlib import Path
from django.conf import settings
from django.core.cache import cache
from django.db.models import Count, Q
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import TAG_CLOUD_CACHE_KEYS, MediaItem, MediaLocation
from .permissions import CanUpload
from .services import signed_media_url
logger = logging.getLogger(__name__)
HASH_FIELDS = ("ahash", "dhash", "phash", "whash")
STORAGE_CACHE_KEY = "j621.library.storage"
STORAGE_CACHE_TTL = 60
GROUPS_PER_PAGE = 20
def parse_threshold(value, default=0.8):
try:
parsed = float(value)
except (TypeError, ValueError):
return default
if parsed > 1:
parsed = parsed / 100.0
return min(max(parsed, 0.0), 1.0)
def parse_algorithms(value):
if not isinstance(value, list):
return list(HASH_FIELDS)
selected = [field for field in value if field in HASH_FIELDS]
return selected or list(HASH_FIELDS)
def hashes_similarity(first_hashes, second_hashes, algorithms, threshold):
"""Best similarity between two hash mappings, or None below threshold."""
best = None
for field in algorithms:
first = first_hashes.get(field) or ""
second = second_hashes.get(field) or ""
if not first or not second:
continue
try:
distance = bin(int(first, 16) ^ int(second, 16)).count("1")
except ValueError:
continue
value = 1.0 - distance / 64.0
if best is None or value > best:
best = value
if best is None or best < threshold:
return None
return best
def similarity_between(item_a, item_b, algorithms, threshold):
"""Best similarity across the selected algorithms, or None below threshold."""
return hashes_similarity(
{field: getattr(item_a, field, "") for field in algorithms},
{field: getattr(item_b, field, "") for field in algorithms},
algorithms,
threshold,
)
def hashed_items(algorithms):
query = Q()
for field in algorithms:
query |= ~Q(**{field: ""})
return list(MediaItem.objects.filter(query).prefetch_related("locations"))
def display_rating(item):
if item.rating:
return item.rating
data = item.e621_data or {}
rating = data.get("rating") if isinstance(data, dict) else None
return rating if rating in {"s", "q", "e"} else ""
def item_brief(item, request=None):
locations = list(item.locations.all())
location = locations[0] if locations else None
user = getattr(request, "user", None)
return {
"j_id": f"J-{item.id}",
"md5": item.md5,
"filename": Path(location.rel_path).name if location else item.md5,
"size": item.size,
"rating": display_rating(item),
"location_count": len(locations),
"uploaded_by": item.uploaded_by.username if item.uploaded_by else None,
"e621_post_id": item.e621_post_id,
"thumbnail_url": signed_media_url(item, user, "thumbnail", request=request),
}
def resolve_item(data):
j_id = str(data.get("j_id") or "").strip()
md5 = str(data.get("md5") or "").strip().lower()
if j_id:
numeric = j_id[2:] if j_id.upper().startswith("J-") else j_id
if numeric.isdigit():
item = MediaItem.objects.filter(pk=int(numeric)).first()
if item is not None:
return item
if md5:
return MediaItem.objects.filter(md5=md5).first()
return None
class ExactDuplicatesView(APIView):
"""Items whose content exists at more than one path."""
permission_classes = [CanUpload]
def get(self, request):
items = (
MediaItem.objects.annotate(location_count=Count("locations"))
.filter(location_count__gt=1)
.prefetch_related("locations")
.order_by("-location_count", "id")
)
groups = []
for item in items:
brief = item_brief(item, request)
brief["locations"] = [
{"id": location.id, "rel_path": location.rel_path}
for location in item.locations.all()
]
groups.append(brief)
return Response({"count": len(groups), "groups": groups})
class VisualMatchesView(APIView):
"""Items visually similar to one library item."""
permission_classes = [CanUpload]
def post(self, request):
threshold = parse_threshold(request.data.get("threshold"))
algorithms = parse_algorithms(request.data.get("algorithms"))
target = resolve_item(request.data)
if target is None:
return Response(
{"detail": "A j_id or md5 is required."},
status=status.HTTP_400_BAD_REQUEST,
)
matches = []
for item in hashed_items(algorithms):
if item.pk == target.pk:
continue
similarity = similarity_between(target, item, algorithms, threshold)
if similarity is None:
continue
brief = item_brief(item, request)
brief["similarity"] = round(similarity * 100, 1)
matches.append(brief)
matches.sort(key=lambda entry: entry["similarity"], reverse=True)
return Response(
{
"target": item_brief(target, request),
"threshold": round(threshold * 100, 1),
"algorithms": algorithms,
"count": len(matches),
"matches": matches[:200],
}
)
class VisualGroupsView(APIView):
"""Groups of visually similar items (union-find over perceptual hashes).
Pairwise comparison is O(n^2) with fast bit operations, which is fine for
a personal library. Revisit with a bucketed index if libraries grow huge.
"""
permission_classes = [CanUpload]
def post(self, request):
threshold = parse_threshold(request.data.get("threshold"))
algorithms = parse_algorithms(request.data.get("algorithms"))
page = max(1, int(request.data.get("page") or 1))
items = hashed_items(algorithms)
parent = list(range(len(items)))
def find(index):
while parent[index] != index:
parent[index] = parent[parent[index]]
index = parent[index]
return index
def union(first, second):
root_a = find(first)
root_b = find(second)
if root_a != root_b:
parent[root_b] = root_a
for first in range(len(items)):
for second in range(first + 1, len(items)):
if find(first) == find(second):
continue
if (
similarity_between(
items[first], items[second], algorithms, threshold
)
is not None
):
union(first, second)
grouped = {}
for index, item in enumerate(items):
grouped.setdefault(find(index), []).append(item)
groups = [members for members in grouped.values() if len(members) >= 2]
groups.sort(key=len, reverse=True)
total = len(groups)
start = (page - 1) * GROUPS_PER_PAGE
page_groups = groups[start : start + GROUPS_PER_PAGE]
return Response(
{
"count": total,
"page": page,
"per_page": GROUPS_PER_PAGE,
"has_next": start + GROUPS_PER_PAGE < total,
"threshold": round(threshold * 100, 1),
"algorithms": algorithms,
"groups": [
{
"size": len(members),
"members": [item_brief(item, request) for item in members],
}
for members in page_groups
],
}
)
def can_delete(user, item):
if user.is_app_staff:
return True
return item.uploaded_by_id == user.id
def remove_watched_file(path):
"""Delete a file only when it lives inside the watched folder."""
watched = Path(settings.WATCHED_FOLDER).resolve()
try:
resolved = Path(path).resolve()
resolved.relative_to(watched)
except (ValueError, OSError):
return False
if resolved.is_file():
resolved.unlink()
return True
return False
class DeleteFilesView(APIView):
"""Delete items (with every copy) or individual duplicate locations."""
permission_classes = [CanUpload]
def post(self, request):
j_ids = request.data.get("j_ids") or []
location_ids = request.data.get("location_ids") or []
if not isinstance(j_ids, list) or not isinstance(location_ids, list):
return Response(
{"detail": "j_ids and location_ids must be lists."},
status=status.HTTP_400_BAD_REQUEST,
)
deleted = []
errors = []
numeric_ids = []
for value in j_ids:
text = str(value).strip()
numeric = text[2:] if text.upper().startswith("J-") else text
if numeric.isdigit():
numeric_ids.append(int(numeric))
for item in MediaItem.objects.filter(pk__in=numeric_ids).prefetch_related(
"locations"
):
if not can_delete(request.user, item):
errors.append({"j_id": f"J-{item.id}", "error": "permission denied"})
continue
for location in item.locations.all():
remove_watched_file(location.path)
deleted.append(f"J-{item.id}")
item.delete()
cache.delete(STORAGE_CACHE_KEY)
cache.delete_many(TAG_CLOUD_CACHE_KEYS)
for location in MediaLocation.objects.filter(
id__in=location_ids
).select_related("item"):
if not can_delete(request.user, location.item):
errors.append(
{"location": location.id, "error": "permission denied"}
)
continue
location_id = location.id
item = location.item
remove_watched_file(location.path)
location.delete()
if item.locations.exists():
deleted.append(f"location {location_id}")
else:
deleted.append(f"J-{item.id}")
item.delete()
return Response({"deleted": deleted, "errors": errors})
class ClearTempView(APIView):
"""Remove staged files from the temp upload folder."""
permission_classes = [CanUpload]
def post(self, request):
temp_dir = Path(settings.MEDIA_ROOT) / "uploads" / "temp"
removed = 0
if temp_dir.exists():
for entry in temp_dir.iterdir():
if entry.is_file():
entry.unlink()
removed += 1
cache.delete(STORAGE_CACHE_KEY)
return Response({"removed": removed})
class TagCloudView(APIView):
"""Most-used tags across the library (custom + e621 tags)."""
permission_classes = [AllowAny]
def get(self, request):
cache_key = (
"j621.library.tag_cloud.auth"
if request.user.is_authenticated
else "j621.library.tag_cloud.guest"
)
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
queryset = MediaItem.objects.all()
if not request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
counts = {}
for item in queryset.only("tags", "e621_data").iterator(chunk_size=500):
names = set()
for tag in item.tags or []:
name = str(tag).strip().lower()
if name:
names.add(name)
data = item.e621_data or {}
categories = data.get("tags") if isinstance(data, dict) else None
if isinstance(categories, dict):
for values in categories.values():
if isinstance(values, list):
names.update(
str(tag).strip().lower()
for tag in values
if str(tag).strip()
)
for name in names:
counts[name] = counts.get(name, 0) + 1
ranked = sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
payload = {
"count": len(counts),
"tags": [
{"tag": tag, "count": count} for tag, count in ranked[:120]
],
}
cache.set(cache_key, payload, 120)
return Response(payload)
def storage_info():
"""Disk usage payload, cached briefly (also used by the stats dashboard)."""
cached = cache.get(STORAGE_CACHE_KEY)
if cached is not None:
return cached
watched = Path(settings.WATCHED_FOLDER)
media_root = Path(settings.MEDIA_ROOT)
temp_dir = media_root / "uploads" / "temp"
usage = shutil.disk_usage(
str(watched) if watched.exists() else str(Path(settings.BASE_DIR))
)
library_size = (
sum(file.stat().st_size for file in watched.rglob("*") if file.is_file())
if watched.exists()
else 0
)
media_size = (
sum(file.stat().st_size for file in media_root.rglob("*") if file.is_file())
if media_root.exists()
else 0
)
temp_files = (
[file for file in temp_dir.rglob("*") if file.is_file()]
if temp_dir.exists()
else []
)
payload = {
"watched_folder": {
"path": str(watched),
"total": usage.total,
"used": usage.used,
"free": usage.free,
"library_size": library_size,
"percent_used": (
round(usage.used / usage.total * 100, 1) if usage.total else 0
),
},
"media": {"path": str(media_root), "size": media_size},
"temp": {
"path": str(temp_dir),
"size": sum(file.stat().st_size for file in temp_files),
"files": len(temp_files),
},
"library_items": MediaItem.objects.count(),
}
cache.set(STORAGE_CACHE_KEY, payload, STORAGE_CACHE_TTL)
return payload
class StorageView(APIView):
"""Disk usage for the watched folder, media root and temp uploads."""
permission_classes = [CanUpload]
def get(self, request):
return Response(storage_info())