From 99f617d296ba33d90f4909d7a6ede2a243a7d753 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 23:18:04 -0500 Subject: [PATCH] Footer storage/backend display and a staff role that actually grants staff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Footer: - Left is now 'Backend Storage:' with a capacity bar (blue, peach at 80%, red at 95% per DESIGN.md) and a used/total/free tooltip; the watched folder path is no longer printed. /api/status/ returns a compact storage summary instead of the path (the full storage page still shows paths to authenticated users). - Centre shows the backend API origin (empty = same origin). Staff get a link to /setup to point the browser elsewhere; everyone else sees it as plain text. The Account 'Backend connection' card is gone — this is installation plumbing, not a per-user setting. - Design spec updated to match. Staff role: - The custom role did nothing on several endpoints that only accepted Django's is_staff/is_superuser. One canonical check now exists: User.is_app_staff (superuser, Django staff, or the staff role), used by the stats/users APIs, item object permissions, can_delete, upload/ similarity/download/match querysets, and the management commands (which also pick staff-role accounts for e621 sync/match and file ownership). Verified with a role-only staff account (is_staff/is_superuser false): stats/users 200, all 32 downloads + 2 scans visible, others' items editable; the same account as role=user gets 403 for all of those. --- backend/apps/accounts/models.py | 7 +++ backend/apps/accounts/views.py | 21 +------ backend/apps/core/permissions.py | 13 ++++ backend/apps/core/views.py | 15 +++-- backend/apps/follows/sync.py | 5 +- .../library/management/commands/match_e621.py | 7 ++- .../library/management/commands/scan_files.py | 13 +++- backend/apps/library/permissions.py | 2 +- backend/apps/library/similarity.py | 2 +- backend/apps/library/tools.py | 2 +- backend/apps/library/uploads.py | 6 +- backend/apps/library/views.py | 10 +--- frontend/design.md_j621_frontend_spec.md | 3 +- frontend/src/components/StatusFooter.tsx | 59 +++++++++++++++++-- frontend/src/features/account/AccountPage.tsx | 2 - frontend/src/features/account/BackendCard.tsx | 27 --------- frontend/src/lib/types.ts | 7 ++- 17 files changed, 123 insertions(+), 78 deletions(-) create mode 100644 backend/apps/core/permissions.py delete mode 100644 frontend/src/features/account/BackendCard.tsx diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index f528e18..fb5ffd8 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -35,3 +35,10 @@ class User(AbstractUser): @property def can_upload(self): return self.is_superuser or self.role in {self.ROLE_UPLOADER, self.ROLE_STAFF} + + @property + def is_app_staff(self): + """Staff in this app: superusers, Django staff, or the staff role.""" + return bool( + self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF + ) diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index e1feb8a..6255b31 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,10 +1,11 @@ -from rest_framework import mixins, permissions, status, viewsets +from rest_framework import mixins, status, viewsets from rest_framework.authtoken.models import Token from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from django.db.models import Count, Q +from apps.core.permissions import IsAppStaff from apps.library.models import MediaItem from .models import User @@ -83,22 +84,6 @@ class E621CredentialsView(APIView): return Response(self._payload(user)) -class IsStaffUser(permissions.BasePermission): - message = "Staff only." - - def has_permission(self, request, view): - user = request.user - return bool( - user - and user.is_authenticated - and ( - user.is_superuser - or user.is_staff - or user.role == user.ROLE_STAFF - ) - ) - - def resolve_avatar_item(value): """Turn a "J-42" / "42" string into a MediaItem. @@ -163,7 +148,7 @@ class UserViewSet( ): """Staff user directory: roles and J-ID avatars.""" - permission_classes = [IsStaffUser] + permission_classes = [IsAppStaff] http_method_names = ["get", "patch", "head", "options"] def get_queryset(self): diff --git a/backend/apps/core/permissions.py b/backend/apps/core/permissions.py new file mode 100644 index 0000000..dfb0f9d --- /dev/null +++ b/backend/apps/core/permissions.py @@ -0,0 +1,13 @@ +from rest_framework import permissions + + +class IsAppStaff(permissions.BasePermission): + """Superusers, Django staff, and accounts with the app's staff role.""" + + message = "Staff only." + + def has_permission(self, request, view): + user = request.user + return bool( + user and user.is_authenticated and user.is_app_staff + ) diff --git a/backend/apps/core/views.py b/backend/apps/core/views.py index 8c34d33..8df1acc 100644 --- a/backend/apps/core/views.py +++ b/backend/apps/core/views.py @@ -31,13 +31,21 @@ class StatusView(APIView): def get(self, request): started = getattr(request, "start_time", time.perf_counter()) + from apps.library.tools import storage_info + + watched = storage_info()["watched_folder"] payload = { "app": "J621", "env": settings.APP_ENV, "git_hash": settings.GIT_COMMIT_HASH, "version": settings.APP_VERSION, "os": get_os_info(), - "watched_folder": settings.WATCHED_FOLDER, + "storage": { + "used": watched["used"], + "total": watched["total"], + "free": watched["free"], + "percent_used": watched["percent_used"], + }, "e621_time_ms": None, # e621 latency is measured client-side "workers": _worker_counts(), } @@ -51,10 +59,7 @@ class StatsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - user = request.user - if not ( - user.is_staff or user.is_superuser or user.role == user.ROLE_STAFF - ): + if not request.user.is_app_staff: raise PermissionDenied("Staff only.") from apps.library.tools import storage_info diff --git a/backend/apps/follows/sync.py b/backend/apps/follows/sync.py index 04c8375..3a9ee84 100644 --- a/backend/apps/follows/sync.py +++ b/backend/apps/follows/sync.py @@ -9,6 +9,7 @@ user following it. import logging from django.contrib.auth import get_user_model +from django.db.models import Q from django.utils import timezone from apps.library import e621 @@ -32,7 +33,9 @@ def preferred_fetch_user(username=None): if username: return User.objects.filter(username=username).first() return ( - User.objects.filter(is_staff=True) + User.objects.filter( + Q(is_superuser=True) | Q(is_staff=True) | Q(role=User.ROLE_STAFF) + ) .exclude(e621_username="") .exclude(e621_api_key="") .order_by("id") diff --git a/backend/apps/library/management/commands/match_e621.py b/backend/apps/library/management/commands/match_e621.py index 1fa4eb1..e5dbf35 100644 --- a/backend/apps/library/management/commands/match_e621.py +++ b/backend/apps/library/management/commands/match_e621.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError +from django.db.models import Q from apps.library import e621, matching from apps.library.models import MatchTask, MediaItem @@ -35,7 +36,11 @@ class Command(BaseCommand): else: user = ( User.objects.filter( - is_staff=True, e621_api_key__gt="", e621_username__gt="" + Q(is_superuser=True) + | Q(is_staff=True) + | Q(role=User.ROLE_STAFF), + e621_api_key__gt="", + e621_username__gt="", ) .order_by("id") .first() diff --git a/backend/apps/library/management/commands/scan_files.py b/backend/apps/library/management/commands/scan_files.py index 4fce7cc..84b9b6b 100644 --- a/backend/apps/library/management/commands/scan_files.py +++ b/backend/apps/library/management/commands/scan_files.py @@ -4,6 +4,7 @@ from pathlib import Path from django.conf import settings from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand +from django.db.models import Q from apps.library.models import MediaLocation from apps.library.services import ALLOWED_EXTENSIONS, index_file @@ -42,11 +43,19 @@ class Command(BaseCommand): ) return else: - owner = User.objects.filter(is_superuser=True).order_by("id").first() + owner = ( + User.objects.filter( + Q(is_superuser=True) + | Q(is_staff=True) + | Q(role=User.ROLE_STAFF) + ) + .order_by("-is_superuser", "id") + .first() + ) if owner is None: self.stderr.write( self.style.WARNING( - "No superuser found; scanned files will have no owner. " + "No staff user found; scanned files will have no owner. " "Use --user to assign one." ) ) diff --git a/backend/apps/library/permissions.py b/backend/apps/library/permissions.py index bab538e..92fee1b 100644 --- a/backend/apps/library/permissions.py +++ b/backend/apps/library/permissions.py @@ -22,6 +22,6 @@ class IsUploaderOrStaffOrReadOnly(permissions.BasePermission): user = request.user if not (user and user.is_authenticated): return False - if user.is_superuser or user.role == user.ROLE_STAFF: + if user.is_app_staff: return True return obj.uploaded_by_id == user.id diff --git a/backend/apps/library/similarity.py b/backend/apps/library/similarity.py index 96813de..aee117e 100644 --- a/backend/apps/library/similarity.py +++ b/backend/apps/library/similarity.py @@ -92,7 +92,7 @@ class SimilarityCheckViewSet( def get_queryset(self): queryset = SimilarityCheck.objects.all() user = self.request.user - if not (user.is_staff or user.is_superuser): + if not user.is_app_staff: queryset = queryset.filter(user=user) return queryset diff --git a/backend/apps/library/tools.py b/backend/apps/library/tools.py index 3852351..8be258d 100644 --- a/backend/apps/library/tools.py +++ b/backend/apps/library/tools.py @@ -250,7 +250,7 @@ class VisualGroupsView(APIView): def can_delete(user, item): - if user.is_superuser or user.role == user.ROLE_STAFF: + if user.is_app_staff: return True return item.uploaded_by_id == user.id diff --git a/backend/apps/library/uploads.py b/backend/apps/library/uploads.py index f7de195..8a63938 100644 --- a/backend/apps/library/uploads.py +++ b/backend/apps/library/uploads.py @@ -155,7 +155,7 @@ class TempUploadViewSet( def get_queryset(self): queryset = TempUpload.objects.select_related("library_item") user = self.request.user - if not (user.is_staff or user.is_superuser): + if not user.is_app_staff: queryset = queryset.filter(user=user) return queryset @@ -227,9 +227,7 @@ class TempUploadViewSet( temp = TempUpload.objects.filter(pk=pk).first() is_owner = temp is not None and temp.user_id == user.id - if temp is None or not ( - is_owner or user.is_staff or user.is_superuser - ): + if temp is None or not (is_owner or user.is_app_staff): raise Http404 if not temp.file: raise Http404 diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index ee5e3cc..42b482c 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -242,11 +242,7 @@ class MediaItemViewSet( def _can_match(self, request, item): user = request.user - return bool( - user.is_superuser - or user.role == user.ROLE_STAFF - or item.uploaded_by_id == user.id - ) + return bool(user.is_app_staff or item.uploaded_by_id == user.id) @action(detail=True, methods=["post"], permission_classes=[CanUpload]) def match(self, request, pk=None): @@ -424,7 +420,7 @@ class DownloadTaskViewSet( if post_id.isdigit(): queryset = queryset.filter(post_id=int(post_id)) user = self.request.user - if not (user.is_staff or user.is_superuser): + if not user.is_app_staff: queryset = queryset.filter(user=user) return queryset @@ -491,7 +487,7 @@ class MatchTaskViewSet( reap_stale_match_tasks() queryset = MatchTask.objects.all() user = self.request.user - if not (user.is_staff or user.is_superuser): + if not user.is_app_staff: queryset = queryset.filter(user=user) return queryset diff --git a/frontend/design.md_j621_frontend_spec.md b/frontend/design.md_j621_frontend_spec.md index 84e039b..9372189 100644 --- a/frontend/design.md_j621_frontend_spec.md +++ b/frontend/design.md_j621_frontend_spec.md @@ -82,7 +82,8 @@ All tag chips, counters, and sidebar taxonomy badges strictly adhere to e621's e - Right: System status pill with real backend values, never hardcoded: environment (`DEV`/`PROD`), git commit hash, the actual host OS, page generation time, and the total e621 API request time for that page load — e.g. `ENV: PROD · a1b2c3d · LINUX · 18ms (e621: 4ms)`. Followed by the user avatar. - **Global Footer Status Strip (`#11111b`, height: 32px):** - The strip placement is a proposal; its values are real and drawn from the existing stats data. - - Left: the watched folder path — `📁 Local Storage: /mnt/media/library`. This is the only place the path appears (J621 is folder-based; it is just a path on disk, not a NAS/appliance indicator). + - Left: `🖴 Backend Storage: 61.3%` — disk usage of the watched folder, colour-coded per the DESIGN.md capacity thresholds, with used/total/free in the tooltip. The folder path is no longer printed in the shell. + - Center: the backend API origin (empty means same origin). Staff can click it to open `/setup` and point the browser at another backend. - Right: `⚡ Active Workers: N running | Queue: M pending` — the count of active optimization/ingest jobs and queued downloads (the original app surfaced this on its stats page; the footer simply keeps it visible). - The build version (` @ `) is shown by the header status pill only — never repeated in the footer. - **Collapsible Sidebar (Behavior):** diff --git a/frontend/src/components/StatusFooter.tsx b/frontend/src/components/StatusFooter.tsx index 94d6b26..83be815 100644 --- a/frontend/src/components/StatusFooter.tsx +++ b/frontend/src/components/StatusFooter.tsx @@ -1,8 +1,18 @@ -import { Folder, Zap } from "lucide-react"; +import { HardDrive, Zap } from "lucide-react"; import { Link } from "react-router-dom"; +import { cn } from "@/lib/cn"; +import { getBackendUrl } from "@/lib/backend"; +import { formatBytes } from "@/lib/format"; import type { SystemStatus } from "@/lib/types"; +/** DESIGN.md capacity bars: blue, peach at 80%, red at 95%. */ +function storageColor(percent: number): string { + if (percent >= 95) return "bg-ctp-red"; + if (percent >= 80) return "bg-ctp-peach"; + return "bg-ctp-blue"; +} + export function StatusFooter({ status, staff, @@ -10,6 +20,9 @@ export function StatusFooter({ status?: SystemStatus; staff?: boolean; }) { + const backend = getBackendUrl() || window.location.origin; + const storage = status?.storage; + const workers = ( <> @@ -26,12 +39,46 @@ export function StatusFooter({