diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a6247c..1a46fb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable user-facing changes to this project will be documented in this file. ## Unreleased +- The public Wall of Shame and validator wallet endpoints no longer reveal the profile identity (name, avatar, profile link) of operators who set their account to non-visible; the validator still appears, identified only by its on-chain operator address (dedbeae) - New Wall of Shame page under Validators publicly flags every active validator that isn't reporting metrics or logs in the last five minutes. The page lists all active validators (SHAME rows first) with their moniker, addresses, operator profile link, network badge, and binary ON/SHAME badges for both metrics and logs. Backed by a five-minute cron that cross-references on-chain active validators against our observability stack (98d083e) - Stewards reviewing submissions can now copy a compact AI-ready bundle (user, contribution, mission, state, submitter notes, evidence URLs, staff reply, proposal, and internal CRM notes) from a copy icon next to each submission's title; the copy aborts with a warning if internal notes can't be loaded so the clipboard payload is never silently incomplete. Evidence URL types gain an admin-editable "Allow duplicate" flag that exempts URLs of that type (for example shared GitHub repositories) from duplicate detection across both manual and automated review (ac68ec7) - Contributions explorer now shows highlighted contributions again, the page footer is flush to the bottom and CTAs users to submit a contribution, and the highlights section collapses to a single compact line when empty so contributions get more room. The Submit Contribution form no longer shows misleading "Please add a description" errors for evidence URLs, detects URL types correctly when users omit `https://`, and disables the submit button when a required GitHub or X URL has no linked social account (b249787) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 69772f5b..852851f5 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -220,19 +220,19 @@ GET /api/auth/verify/ POST /api/auth/logout/ # Users -GET /api/v1/users/ +GET /api/v1/users/ (requires auth) GET /api/v1/users/me/ (requires auth) PATCH /api/v1/users/me/ (requires auth, only name) -GET /api/v1/users/{address}/ -GET /api/v1/users/by-address/{address}/ -GET /api/v1/users/validators/ +GET /api/v1/users/{address}/ (requires auth) +GET /api/v1/users/by-address/{address}/ (requires auth) +GET /api/v1/users/validators/ (requires auth) POST /api/v1/users/link_x_account/ (requires auth, awards 20 pts for linking X) POST /api/v1/users/link_discord_account/ (requires auth, awards 20 pts for linking Discord) # Contributions -GET /api/v1/contributions/ +GET /api/v1/contributions/ (requires auth) POST /api/v1/contributions/ (requires auth) -GET /api/v1/contributions/{id}/ +GET /api/v1/contributions/{id}/ (requires auth) PATCH /api/v1/contributions/{id}/ (requires auth) DELETE /api/v1/contributions/{id}/ (requires auth) @@ -242,26 +242,26 @@ POST /api/v1/submissions/{id}/appeal/ (requires auth, owner-only, one POST /api/v1/submissions/{id}/add-evidence/ (requires auth, owner-only) # Contribution Types -GET /api/v1/contribution-types/ -GET /api/v1/contribution-types/{id}/ -GET /api/v1/contribution-types/statistics/ +GET /api/v1/contribution-types/ (requires auth) +GET /api/v1/contribution-types/{id}/ (requires auth) +GET /api/v1/contribution-types/statistics/ (requires auth) # Leaderboard -GET /api/v1/leaderboard/ -GET /api/v1/leaderboard/stats/ -GET /api/v1/leaderboard/user_stats/by-address/{address}/ +GET /api/v1/leaderboard/ (requires auth) +GET /api/v1/leaderboard/stats/ (requires auth) +GET /api/v1/leaderboard/user_stats/by-address/{address}/ (requires auth) # Multipliers -GET /api/v1/multipliers/ +GET /api/v1/multipliers/ (requires auth) GET /api/v1/multiplier-periods/ # Validators - Wall of Shame POST /api/v1/validators/wallets/sync-grafana/ (cron-protected, X-Cron-Token, background) GET /api/v1/validators/wallets/wall-of-shame/ (public, cached 60s, ?network= filter) -# Steward Submissions (public metrics) -GET /api/v1/steward-submissions/stats/ (public - aggregate stats) -GET /api/v1/steward-submissions/daily-metrics/ (public - time-series data) +# Steward Submissions +GET /api/v1/steward-submissions/stats/ (requires steward) +GET /api/v1/steward-submissions/daily-metrics/ (requires steward) # AI Review Agent GET /api/v1/ai-review/ diff --git a/backend/api/metrics_views.py b/backend/api/metrics_views.py index d21aed86..eedb955a 100644 --- a/backend/api/metrics_views.py +++ b/backend/api/metrics_views.py @@ -3,7 +3,7 @@ import requests from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework import permissions, status from django.core.cache import cache from django.db.models import Count, Q, Min from django.utils import timezone @@ -19,7 +19,6 @@ class ActiveValidatorsView(APIView): Get active validators based on their first uptime contribution. Returns data points showing validator activation over time with continuous dates. """ - def get(self, request): from django.db.models.functions import TruncDate from datetime import date, timedelta @@ -91,7 +90,6 @@ class ContributionTypesStatsView(APIView): """ Get time series data showing how many contribution types have been assigned on each date. """ - def get(self, request): from django.db.models.functions import TruncDate from datetime import date, timedelta @@ -169,6 +167,7 @@ class ParticipantsGrowthView(APIView): require `user.visible=True`, matching the Dashboard `/leaderboard/stats/` definitions so the time series and the live counts agree. """ + permission_classes = [permissions.AllowAny] EXCLUDED_BUILDER_SLUGS = ('builder-welcome', 'builder') @@ -307,6 +306,7 @@ class TestnetMetricsView(APIView): explorer hosts don't serve CORS headers, so we proxy from Django and cache the aggregate KPIs for a short window. """ + permission_classes = [permissions.AllowAny] EXPLORER_BASE_URLS = { 'asimov': 'https://explorer-asimov.genlayer.com', diff --git a/backend/api/tests.py b/backend/api/tests.py index 44b957fb..fb5dc1d4 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -2,6 +2,7 @@ from django.test import TestCase from django.utils import timezone +from rest_framework import status from rest_framework.test import APIClient from builders.models import Builder @@ -13,40 +14,71 @@ class ParticipantsGrowthViewTests(TestCase): def setUp(self): self.client = APIClient() - self.validator_category = Category.objects.create( - name='Validator', - slug='validator' + self.authenticated_user = User.objects.create_user( + email='metrics@example.com', + password='pass', + address='0x9999999999999999999999999999999999999999', + ) + self.client.force_authenticate(user=self.authenticated_user) + self.validator_category, _ = Category.objects.get_or_create( + slug='validator', + defaults={'name': 'Validator'}, ) - self.builder_category = Category.objects.create( - name='Builder', - slug='builder' + self.builder_category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder'}, ) - self.waitlist_type = ContributionType.objects.create( - name='Validator Waitlist', + self.waitlist_type, _ = ContributionType.objects.get_or_create( slug='validator-waitlist', - category=self.validator_category + defaults={ + 'name': 'Validator Waitlist', + 'category': self.validator_category, + }, ) - self.builder_welcome_type = ContributionType.objects.create( - name='Builder Welcome', + self.builder_welcome_type, _ = ContributionType.objects.get_or_create( slug='builder-welcome', - category=self.builder_category + defaults={ + 'name': 'Builder Welcome', + 'category': self.builder_category, + }, ) - self.builder_real_type = ContributionType.objects.create( - name='Builder Submission', + self.builder_real_type, _ = ContributionType.objects.get_or_create( slug='builder-submission', - category=self.builder_category + defaults={ + 'name': 'Builder Submission', + 'category': self.builder_category, + }, ) - self.validator_real_type = ContributionType.objects.create( - name='Uptime', + self.validator_real_type, _ = ContributionType.objects.get_or_create( slug='uptime', - category=self.validator_category + defaults={ + 'name': 'Uptime', + 'category': self.validator_category, + }, ) - self.validator_graduation_type = ContributionType.objects.create( - name='Validator', + self.validator_graduation_type, _ = ContributionType.objects.get_or_create( slug='validator', - category=self.validator_category + defaults={ + 'name': 'Validator', + 'category': self.validator_category, + }, ) + def test_participants_growth_allows_public_metrics_page(self): + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/metrics/participants-growth/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + + def test_testnet_metrics_allows_public_metrics_page(self): + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/metrics/testnet-kpis/?network=unknown') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def _create_user(self, email, address): return User.objects.create_user( email=email, diff --git a/backend/contributions/migrations/0037_seed_featured_content.py b/backend/contributions/migrations/0037_seed_featured_content.py index 922ace6a..72c64aa5 100644 --- a/backend/contributions/migrations/0037_seed_featured_content.py +++ b/backend/contributions/migrations/0037_seed_featured_content.py @@ -1,69 +1,105 @@ from django.db import migrations +def get_seed_user(User, email, fallback_email, name, address): + user = User.objects.filter(email=email).first() + if user: + return user + + user, _ = User.objects.get_or_create( + email=fallback_email, + defaults={ + 'name': name, + 'username': name, + 'address': address, + }, + ) + return user + + def seed_featured_content(apps, schema_editor): User = apps.get_model('users', 'User') FeaturedContent = apps.get_model('contributions', 'FeaturedContent') - albert = User.objects.get(email='albert@genlayer.foundation') # cognocracy - ivan = User.objects.get(email='ivan@genlayer.foundation') # raskovsky + albert = get_seed_user( + User, + email='albert@genlayer.foundation', + fallback_email='cognocracy@seed.genlayer.com', + name='cognocracy', + address='0x00000000000000000000000000000000000000a1', + ) + ivan = get_seed_user( + User, + email='ivan@genlayer.foundation', + fallback_email='raskovsky@seed.genlayer.com', + name='raskovsky', + address='0x00000000000000000000000000000000000000a2', + ) - FeaturedContent.objects.create( + FeaturedContent.objects.update_or_create( content_type='hero', title='Argue.fun Launch', - description='Deploy intelligent contracts, run validators, and earn GenLayer Points on the latest testnet.', - subtitle='cognocracy', - user=albert, - hero_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117991/tally/featured_1_hero_1772117989.png', - hero_image_public_id='tally/featured_1_hero_1772117989', - url='', - is_active=True, - order=0, + defaults={ + 'description': 'Deploy intelligent contracts, run validators, and earn GenLayer Points on the latest testnet.', + 'subtitle': 'cognocracy', + 'user': albert, + 'hero_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117991/tally/featured_1_hero_1772117989.png', + 'hero_image_public_id': 'tally/featured_1_hero_1772117989', + 'url': '', + 'is_active': True, + 'order': 0, + }, ) - FeaturedContent.objects.create( + FeaturedContent.objects.update_or_create( content_type='build', title='Argue.fun', - description='', - subtitle='', - user=albert, - hero_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117992/tally/featured_2_hero_1772117992.png', - hero_image_public_id='tally/featured_2_hero_1772117992', - user_profile_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117994/tally/featured_2_avatar_1772117993.png', - user_profile_image_public_id='tally/featured_2_avatar_1772117993', - url='', - is_active=True, - order=0, + defaults={ + 'description': '', + 'subtitle': '', + 'user': albert, + 'hero_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117992/tally/featured_2_hero_1772117992.png', + 'hero_image_public_id': 'tally/featured_2_hero_1772117992', + 'user_profile_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117994/tally/featured_2_avatar_1772117993.png', + 'user_profile_image_public_id': 'tally/featured_2_avatar_1772117993', + 'url': '', + 'is_active': True, + 'order': 0, + }, ) - FeaturedContent.objects.create( + FeaturedContent.objects.update_or_create( content_type='build', title='Internet Court', - description='', - subtitle='', - user=ivan, - hero_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117994/tally/featured_3_hero_1772117994.png', - hero_image_public_id='tally/featured_3_hero_1772117994', - user_profile_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117995/tally/featured_3_avatar_1772117995.png', - user_profile_image_public_id='tally/featured_3_avatar_1772117995', - url='', - is_active=True, - order=1, + defaults={ + 'description': '', + 'subtitle': '', + 'user': ivan, + 'hero_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117994/tally/featured_3_hero_1772117994.png', + 'hero_image_public_id': 'tally/featured_3_hero_1772117994', + 'user_profile_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117995/tally/featured_3_avatar_1772117995.png', + 'user_profile_image_public_id': 'tally/featured_3_avatar_1772117995', + 'url': '', + 'is_active': True, + 'order': 1, + }, ) - FeaturedContent.objects.create( + FeaturedContent.objects.update_or_create( content_type='build', title='Rally', - description='', - subtitle='', - user=ivan, - hero_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117996/tally/featured_4_hero_1772117995.png', - hero_image_public_id='tally/featured_4_hero_1772117995', - user_profile_image_url='https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117996/tally/featured_4_avatar_1772117996.png', - user_profile_image_public_id='tally/featured_4_avatar_1772117996', - url='', - is_active=True, - order=2, + defaults={ + 'description': '', + 'subtitle': '', + 'user': ivan, + 'hero_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117996/tally/featured_4_hero_1772117995.png', + 'hero_image_public_id': 'tally/featured_4_hero_1772117995', + 'user_profile_image_url': 'https://res.cloudinary.com/dfqmoeawa/image/upload/v1772117996/tally/featured_4_avatar_1772117996.png', + 'user_profile_image_public_id': 'tally/featured_4_avatar_1772117996', + 'url': '', + 'is_active': True, + 'order': 2, + }, ) diff --git a/backend/contributions/migrations/0051_zero_validator_waitlist_points.py b/backend/contributions/migrations/0051_zero_validator_waitlist_points.py index 5a62c8c2..7bdcb360 100644 --- a/backend/contributions/migrations/0051_zero_validator_waitlist_points.py +++ b/backend/contributions/migrations/0051_zero_validator_waitlist_points.py @@ -37,8 +37,11 @@ def restore_validator_waitlist_points(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ + ('builders', '0001_initial'), ('contributions', '0050_evidence_url_types'), ('leaderboard', '0014_add_referral_points_model'), + ('users', '0014_add_referral_system'), + ('validators', '0001_initial'), ] operations = [ diff --git a/backend/contributions/migrations/0063_featuredcontent_unique_content_type_title.py b/backend/contributions/migrations/0063_featuredcontent_unique_content_type_title.py new file mode 100644 index 00000000..27866917 --- /dev/null +++ b/backend/contributions/migrations/0063_featuredcontent_unique_content_type_title.py @@ -0,0 +1,41 @@ +# Generated by Codex on 2026-06-04 + +from django.db import migrations, models + + +def dedupe_featured_content(apps, schema_editor): + FeaturedContent = apps.get_model('contributions', 'FeaturedContent') + db_alias = schema_editor.connection.alias + + duplicate_groups = ( + FeaturedContent.objects.using(db_alias) + .values('content_type', 'title') + .annotate(count=models.Count('id'), keep_id=models.Min('id')) + .filter(count__gt=1) + ) + + for group in duplicate_groups: + ( + FeaturedContent.objects.using(db_alias) + .filter(content_type=group['content_type'], title=group['title']) + .exclude(id=group['keep_id']) + .delete() + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0062_merge_0061_community_discord_xp_0061_featuredcontent_hero_placements'), + ] + + operations = [ + migrations.RunPython(dedupe_featured_content, migrations.RunPython.noop), + migrations.AddConstraint( + model_name='featuredcontent', + constraint=models.UniqueConstraint( + fields=('content_type', 'title'), + name='unique_featured_content_type_title', + ), + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 30e1b339..727cdae1 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -1169,6 +1169,12 @@ class FeaturedContent(BaseModel): class Meta: ordering = ['order', '-created_at'] + constraints = [ + models.UniqueConstraint( + fields=['content_type', 'title'], + name='unique_featured_content_type_title', + ), + ] def __str__(self): return f"{self.get_content_type_display()}: {self.title}" diff --git a/backend/contributions/tests/test_highlights_api.py b/backend/contributions/tests/test_highlights_api.py index 855fbea1..38dc6897 100644 --- a/backend/contributions/tests/test_highlights_api.py +++ b/backend/contributions/tests/test_highlights_api.py @@ -15,6 +15,12 @@ class ContributionHighlightsAPITest(TestCase): def setUp(self): self.client = APIClient() + self.viewer = User.objects.create_user( + email='highlights-viewer@test.com', + address='0x9999999999999999999999999999999999999999', + password='testpass123', + ) + self.client.force_authenticate(user=self.viewer) self.category = Category.objects.create( name='Highlights Test', slug='highlights-test', @@ -53,6 +59,16 @@ def setUp(self): description='Highlighted contribution', ) + def test_highlights_requires_authentication(self): + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/contributions/highlights/') + + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) + def test_highlights_default_limit_is_kept_for_summary_views(self): response = self.client.get('/api/v1/contributions/highlights/') diff --git a/backend/contributions/tests/test_is_submittable.py b/backend/contributions/tests/test_is_submittable.py index 8b342d06..d308aacd 100644 --- a/backend/contributions/tests/test_is_submittable.py +++ b/backend/contributions/tests/test_is_submittable.py @@ -12,6 +12,12 @@ class ContributionTypeIsSubmittableTest(TestCase): def setUp(self): """Set up test data.""" self.client = APIClient() + self.user = User.objects.create_user( + email='contribution-type-viewer@example.com', + password='password123', + address='0x0000000000000000000000000000000000000001', + ) + self.client.force_authenticate(user=self.user) # Get or create test categories to avoid conflicts self.validator_category, _ = Category.objects.get_or_create( @@ -62,6 +68,16 @@ def setUp(self): ) self.api_url = reverse('contributiontype-list') + + def test_contribution_types_require_authentication(self): + self.client.force_authenticate(user=None) + + response = self.client.get(self.api_url) + + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) def test_is_submittable_field_default_value(self): """Test that is_submittable defaults to True.""" @@ -94,7 +110,10 @@ def test_filter_by_is_submittable_true(self): def test_filter_by_is_submittable_false(self): """Test filtering contribution types where is_submittable=false.""" - response = self.client.get(self.api_url, {'is_submittable': 'false'}) + response = self.client.get(self.api_url, { + 'category': 'test-validator', + 'is_submittable': 'false', + }) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -237,6 +256,7 @@ def setUp(self): address='0x1234567890123456789012345678901234567890', is_staff=True ) + self.client.force_authenticate(user=self.steward_user) self.api_url = reverse('contributiontype-list') @@ -259,4 +279,4 @@ def test_steward_can_see_all_types_without_filter(self): non_submittable_count = sum(1 for item in test_types if not item['is_submittable']) self.assertEqual(submittable_count, 3) # Types 0, 2, 4 - self.assertEqual(non_submittable_count, 2) # Types 1, 3 \ No newline at end of file + self.assertEqual(non_submittable_count, 2) # Types 1, 3 diff --git a/backend/contributions/tests/test_public_explorer_filters.py b/backend/contributions/tests/test_public_explorer_filters.py index 92762879..d0cfcec0 100644 --- a/backend/contributions/tests/test_public_explorer_filters.py +++ b/backend/contributions/tests/test_public_explorer_filters.py @@ -25,6 +25,7 @@ def setUp(self): address='0x0000000000000000000000000000000000000001', password='testpass123', ) + self.client.force_authenticate(user=self.user) self.submittable_type = self._create_type( 'Submittable Explorer Type', @@ -102,6 +103,19 @@ def _result_ids(self, response): data = response.json() return {item['id'] for item in data['results']} + def test_contributions_require_authentication(self): + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/contributions/', { + 'category': self.category.slug, + 'public_explorer_only': 'true', + }) + + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) + def test_public_explorer_includes_public_non_submittable_types(self): response = self.client.get('/api/v1/contributions/', { 'category': self.category.slug, diff --git a/backend/contributions/tests/test_submission_limits.py b/backend/contributions/tests/test_submission_limits.py index 17394386..598ca751 100644 --- a/backend/contributions/tests/test_submission_limits.py +++ b/backend/contributions/tests/test_submission_limits.py @@ -266,6 +266,7 @@ def test_contribution_type_api_exposes_capacity_fields(self): self.contribution_type.max_submissions = 2 self.contribution_type.save(update_fields=['max_submissions']) self._create_submission(state='pending') + self.client.force_authenticate(user=self.user) response = self.client.get( f'/api/v1/contribution-types/{self.contribution_type.id}/' diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 23eb1540..d56c96c9 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -67,7 +67,7 @@ class ContributionTypeViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = ContributionType.objects.all() serializer_class = ContributionTypeSerializer - permission_classes = [permissions.AllowAny] # Allow read-only access without authentication + permission_classes = [permissions.IsAuthenticated] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['name', 'description'] ordering_fields = ['name', 'created_at'] @@ -118,7 +118,7 @@ def get_queryset(self): 'required_discord_roles', ).order_by('category__name', 'name', 'id') - @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) + @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def statistics(self, request): """ Get aggregated statistics for each contribution type. @@ -163,7 +163,7 @@ def statistics(self, request): return Response(list(types_with_stats)) - @action(detail=True, methods=['get'], permission_classes=[permissions.AllowAny]) + @action(detail=True, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def top_contributors(self, request, pk=None): """ Get top 10 contributors for a specific contribution type. @@ -202,7 +202,7 @@ def top_contributors(self, request, pk=None): return Response(result) - @action(detail=True, methods=['get'], permission_classes=[permissions.AllowAny]) + @action(detail=True, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def recent_contributions(self, request, pk=None): """ Get the last 10 contributions for a specific contribution type. @@ -229,7 +229,7 @@ def recent_contributions(self, request, pk=None): serializer = ContributionSerializer(recent_contributions, many=True, context=context) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=[permissions.AllowAny]) + @action(detail=True, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def highlights(self, request, pk=None): """ Get active highlights for a specific contribution type. @@ -261,7 +261,7 @@ class ContributionViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = Contribution.objects.all().order_by('-contribution_date') serializer_class = ContributionSerializer - permission_classes = [permissions.AllowAny] # Allow read-only access without authentication + permission_classes = [permissions.IsAuthenticated] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = ['user', 'contribution_type', 'mission'] search_fields = ['notes', 'user__name', 'user__address', 'contribution_type__name'] @@ -346,7 +346,7 @@ def get_serializer_context(self): context['use_light_serializers'] = self.action == 'list' return context - @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) + @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def highlights(self, request): """ Get all active highlights across all contribution types. @@ -459,7 +459,7 @@ class EvidenceViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = Evidence.objects.all().order_by('-created_at') serializer_class = EvidenceSerializer - permission_classes = [permissions.AllowAny] # Allow read-only access without authentication + permission_classes = [permissions.IsAuthenticated] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = ['contribution', 'contribution__user'] search_fields = ['description', 'url'] @@ -1672,10 +1672,8 @@ class StewardSubmissionViewSet(viewsets.ModelViewSet): def get_permissions(self): """ Instantiates and returns the list of permissions that this view requires. - Stats and daily_metrics endpoints are public, all others require steward permission. + Steward review data, including aggregate stats, requires steward permission. """ - if self.action in ['stats', 'daily_metrics']: - return [permissions.AllowAny()] return super().get_permissions() def get_queryset(self): diff --git a/backend/ethereum_auth/tests.py b/backend/ethereum_auth/tests.py new file mode 100644 index 00000000..8486d7cc --- /dev/null +++ b/backend/ethereum_auth/tests.py @@ -0,0 +1,37 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + + +User = get_user_model() + + +class EthereumAuthResponseSecurityTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email='wallet@example.com', + password='testpass123', + address='0x1234567890abcdef1234567890abcdef12345678', + is_email_verified=True, + ) + + def test_verify_auth_does_not_return_session_key(self): + session = self.client.session + session['authenticated'] = True + session['ethereum_address'] = self.user.address + session.save() + + response = self.client.get('/api/auth/verify/') + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['authenticated']) + self.assertEqual(response.data['address'], self.user.address) + self.assertNotIn('session_key', response.data) + + def test_unauthenticated_verify_auth_does_not_return_session_key(self): + response = self.client.get('/api/auth/verify/') + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data['authenticated']) + self.assertNotIn('session_key', response.data) diff --git a/backend/ethereum_auth/views.py b/backend/ethereum_auth/views.py index 6d8238a3..32f91621 100644 --- a/backend/ethereum_auth/views.py +++ b/backend/ethereum_auth/views.py @@ -2,6 +2,7 @@ import string from datetime import timedelta +from django.conf import settings from django.contrib.auth import get_user_model from django.utils import timezone from rest_framework import status @@ -160,7 +161,6 @@ def login(request): 'address': ethereum_address, 'user_id': user.id, 'created': created, - 'session_key': request.session.session_key, # For debugging 'referral_code': user.referral_code, 'referred_by': { 'id': user.referred_by.id, @@ -171,8 +171,10 @@ def login(request): }) except Exception as e: + logger.exception("Authentication failed") + error_detail = f'Authentication failed: {str(e)}' if settings.DEBUG else 'Authentication failed.' return Response( - {'error': f'Authentication failed: {str(e)}'}, + {'error': error_detail}, status=status.HTTP_400_BAD_REQUEST ) @@ -192,8 +194,7 @@ def verify_auth(request): return Response({ 'authenticated': True, 'address': ethereum_address, - 'user_id': user.id, - 'session_key': request.session.session_key # For debugging + 'user_id': user.id }) except User.DoesNotExist: pass @@ -231,4 +232,4 @@ def refresh_session(request): return Response( {'error': 'Not authenticated.'}, status=status.HTTP_401_UNAUTHORIZED - ) \ No newline at end of file + ) diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 3e5d75a1..563b35da 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -21,6 +21,12 @@ class LeaderboardStatsTest(TestCase): def setUp(self): self.client = APIClient() + self.viewer = User.objects.create_user( + email='leaderboard-viewer@example.com', + password='pass', + address='0xffffffffffffffffffffffffffffffffffffffff', + ) + self.client.force_authenticate(user=self.viewer) self.community_category, _ = Category.objects.get_or_create( slug='community', defaults={'name': 'Community'} @@ -128,6 +134,13 @@ def _create_current_mee6_xp(self, user, discord_id, xp): synced_at=now, ) + def test_leaderboard_requires_authentication(self): + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/leaderboard/') + + self.assertIn(response.status_code, [401, 403]) + def test_community_member_count_uses_accepted_community_contributions(self): now = timezone.now() community_user = self._create_user( diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 83e52b8f..f0780b40 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -25,7 +25,7 @@ class GlobalLeaderboardMultiplierViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = GlobalLeaderboardMultiplier.objects.all() serializer_class = GlobalLeaderboardMultiplierSerializer - permission_classes = [permissions.AllowAny] # Allow read-only access without authentication + permission_classes = [permissions.IsAuthenticated] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = ['contribution_type'] search_fields = ['contribution_type__name', 'notes', 'description'] @@ -59,7 +59,7 @@ class LeaderboardViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = LeaderboardEntry.objects.filter(user__visible=True) serializer_class = LeaderboardEntrySerializer - permission_classes = [permissions.AllowAny] # Allow access without authentication + permission_classes = [permissions.IsAuthenticated] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['user__name', 'user__address'] ordering_fields = ['rank', 'total_points', 'updated_at'] diff --git a/backend/poaps/tests/test_poaps.py b/backend/poaps/tests/test_poaps.py index 96b04f64..bd31136b 100644 --- a/backend/poaps/tests/test_poaps.py +++ b/backend/poaps/tests/test_poaps.py @@ -308,6 +308,26 @@ def test_list_and_profile_poaps_are_query_bounded(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertLessEqual(len(profile_queries), 6) + def test_profile_poaps_require_authentication(self): + PoapClaim.objects.create( + drop=self.drop, + user=self.user, + claim_method=PoapClaim.CLAIM_LEGACY, + ) + + response = self.client.get(f'/api/v1/users/by-address/{self.user.address}/poaps/') + + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/v1/users/by-address/{self.user.address}/poaps/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['results'][0]['drop']['slug'], self.drop.slug) + def test_list_reports_claimability_from_distribution_and_capacity(self): live_drop = PoapDrop.objects.create( title='Live Drop', diff --git a/backend/poaps/views.py b/backend/poaps/views.py index 893cf449..00f729da 100644 --- a/backend/poaps/views.py +++ b/backend/poaps/views.py @@ -322,7 +322,11 @@ def verify_wallet(self, request): class UserPoapMixin: - @action(detail=False, methods=['get'], url_path='by-address/(?P
[^/.]+)/poaps') + @action( + detail=False, + methods=['get'], + url_path='by-address/(?P[^/.]+)/poaps', + ) def poaps(self, request, address=None): from django.shortcuts import get_object_or_404 from users.models import User diff --git a/backend/projects/migrations/0002_ensure_project_participants_table.py b/backend/projects/migrations/0002_ensure_project_participants_table.py new file mode 100644 index 00000000..09cb79e7 --- /dev/null +++ b/backend/projects/migrations/0002_ensure_project_participants_table.py @@ -0,0 +1,34 @@ +from django.db import migrations + + +def ensure_project_participants_table(apps, schema_editor): + """ + Defensive idempotent safeguard for Project.participants. + + 0001_initial defines the ManyToManyField; this migration only inspects the + through_model table and creates it if it is missing. Any missing table should + be investigated through migration history/state rather than treating + 0001_initial as incomplete. + """ + Project = apps.get_model('projects', 'Project') + through_model = Project.participants.through + table_name = through_model._meta.db_table + with schema_editor.connection.cursor() as cursor: + existing_tables = schema_editor.connection.introspection.table_names(cursor) + + if table_name not in existing_tables: + schema_editor.create_model(through_model) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.RunPython( + ensure_project_participants_table, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/tally/settings.py b/backend/tally/settings.py index 4f452760..1df75c07 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -198,7 +198,7 @@ def get_required_env(key): 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', # Changed to AllowAny since all views are read-only + 'rest_framework.permissions.IsAuthenticated', ], 'DEFAULT_FILTER_BACKENDS': [ 'django_filters.rest_framework.DjangoFilterBackend', @@ -287,6 +287,11 @@ def get_required_env(key): SESSION_COOKIE_DOMAIN = None # Allow cookies on localhost SESSION_SAVE_EVERY_REQUEST = True # Save session on every request to extend expiry +# Only send the session/CSRF cookies over HTTPS in production. In DEBUG the +# dev server runs over plain HTTP, so keep these off locally. +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG + # Dynamic session cookie name based on port to prevent conflicts in multi-port development # This allows running multiple instances on different ports without session conflicts import sys diff --git a/backend/users/tests/test_email_security.py b/backend/users/tests/test_email_security.py index c4cf3dc2..43f4cfef 100644 --- a/backend/users/tests/test_email_security.py +++ b/backend/users/tests/test_email_security.py @@ -122,6 +122,12 @@ def authenticate(self, user): """Authenticate the client with the given user.""" refresh = RefreshToken.for_user(user) self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}') + + def assert_requires_authentication(self, response): + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) def test_unverified_email_not_exposed_in_own_profile(self): """Test that unverified email is not exposed when user views own profile.""" @@ -144,16 +150,10 @@ def test_verified_email_shown_in_own_profile(self): self.assertTrue(response.data['is_email_verified']) def test_unverified_email_not_exposed_in_public_profile(self): - """Test that unverified email is not exposed in public profile endpoint.""" - # No authentication - public access - - # Test /api/v1/users/by-address/{address}/ + """Test that user profile endpoint requires auth and hides email from other users.""" response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertNotIn('email', response.data) - self.assertNotIn('is_email_verified', response.data) + self.assert_requires_authentication(response) - # Also test when authenticated as another user self.authenticate(self.other_user) response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -161,34 +161,14 @@ def test_unverified_email_not_exposed_in_public_profile(self): self.assertNotIn('is_email_verified', response.data) def test_verified_email_not_shown_in_public_profile(self): - """Test that verified email is not exposed in public profile endpoint.""" - # No authentication - public access - - # Test /api/v1/users/by-address/{address}/ + """Test that verified email is not exposed to anonymous or other users.""" response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - for field in [ - 'id', - 'visible', - 'email', - 'is_email_verified', - 'is_banned', - 'ban_reason', - 'referral_code', - 'referred_by_info', - 'total_referrals', - 'referral_details', - 'github_linked_at', - ]: - self.assertNotIn(field, response.data) + self.assert_requires_authentication(response) - # Also test when authenticated as another user self.authenticate(self.other_user) response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') self.assertEqual(response.status_code, status.HTTP_200_OK) for field in [ - 'id', - 'visible', 'email', 'is_email_verified', 'is_banned', @@ -197,12 +177,12 @@ def test_verified_email_not_shown_in_public_profile(self): 'referred_by_info', 'total_referrals', 'referral_details', - 'github_linked_at', ]: self.assertNotIn(field, response.data) def test_public_profile_only_exposes_public_social_identifiers(self): - """Test that public profiles do not expose social credentials or internals.""" + """Test that authenticated profile lookup does not expose social credentials or internals.""" + self.authenticate(self.other_user) response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -262,24 +242,26 @@ def test_email_becomes_visible_after_verification(self): # User updates their email (which marks it as verified) response = self.client.patch('/api/v1/users/me/', { - 'email': 'newemail@example.com' + 'email': 'newemail@gmail.com' }) self.assertEqual(response.status_code, status.HTTP_200_OK) # Now email should be visible to the owner response = self.client.get('/api/v1/users/me/') - self.assertEqual(response.data['email'], 'newemail@example.com') + self.assertEqual(response.data['email'], 'newemail@gmail.com') self.assertTrue(response.data['is_email_verified']) - # Public endpoint should still not expose it to anonymous clients + # User endpoint should reject anonymous clients self.client.credentials() # Clear authentication response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') - self.assertNotIn('email', response.data) - self.assertNotIn('is_email_verified', response.data) + self.assert_requires_authentication(response) def test_no_email_field_leakage_in_list_view(self): - """Test that auth emails are not exposed in user list view.""" - # Test /api/v1/users/ list endpoint + """Test that auth emails are not exposed in authenticated user list view.""" + response = self.client.get('/api/v1/users/') + self.assert_requires_authentication(response) + + self.authenticate(self.other_user) response = self.client.get('/api/v1/users/') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -338,7 +320,11 @@ def test_no_email_field_leakage_in_list_view(self): ) def test_hidden_users_are_not_publicly_enumerable(self): - """Test that hidden users are excluded from public list and profile endpoints.""" + """Test that hidden users are excluded from non-owner list and profile endpoints.""" + response = self.client.get('/api/v1/users/') + self.assert_requires_authentication(response) + + self.authenticate(self.other_user) response = self.client.get('/api/v1/users/') self.assertEqual(response.status_code, status.HTTP_200_OK) addresses = [user_data['address'] for user_data in response.data['results']] @@ -353,7 +339,11 @@ def test_hidden_users_are_not_publicly_enumerable(self): self.assertEqual(response.data['email'], 'hidden@example.com') def test_public_search_does_not_match_auth_email(self): - """Test that public user search cannot use auth email as a lookup key.""" + """Test that user search requires auth and cannot use auth email as a lookup key.""" + response = self.client.get('/api/v1/users/search/', {'q': 'verified@example.com'}) + self.assert_requires_authentication(response) + + self.authenticate(self.other_user) response = self.client.get('/api/v1/users/search/', {'q': 'verified@example.com'}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, []) @@ -382,6 +372,10 @@ def test_leaderboard_search_does_not_match_auth_email(self): rank=1, ) + response = self.client.get('/api/v1/leaderboard/', {'search': 'verified@example.com'}) + self.assert_requires_authentication(response) + + self.authenticate(self.other_user) response = self.client.get('/api/v1/leaderboard/', {'search': 'verified@example.com'}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, []) diff --git a/backend/users/views.py b/backend/users/views.py index 97d55f86..5e108363 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -35,7 +35,7 @@ class UserViewSet(UserPoapMixin, viewsets.ReadOnlyModelViewSet): """ queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = [permissions.AllowAny] # Allow read-only access without authentication + permission_classes = [permissions.IsAuthenticated] lookup_field = 'address' # Change default lookup field from 'pk' to 'address' filter_backends = [filters.OrderingFilter] ordering_fields = ['date_joined', 'created_at'] @@ -871,7 +871,7 @@ def referrals(self, request): from leaderboard.models import get_referral_breakdown return Response(get_referral_breakdown(request.user)) - @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) + @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def search(self, request): """Search visible users by public identifiers.""" query = request.query_params.get('q', '').strip() diff --git a/backend/validators/grafana_service.py b/backend/validators/grafana_service.py index 9f62aead..d821f3ea 100644 --- a/backend/validators/grafana_service.py +++ b/backend/validators/grafana_service.py @@ -171,7 +171,14 @@ def sync_network(cls, network): wallets = list( ValidatorWallet.objects .filter(network=network, status='active') - .only('id', 'address') + .only( + 'id', + 'address', + 'metrics_status', + 'logs_status', + 'metrics_shame_started_at', + 'logs_shame_started_at', + ) ) if not wallets: @@ -188,8 +195,23 @@ def sync_network(cls, network): vname = name_by_addr.get(addr_lower) logs_ok = bool(vname and log_counts.get(vname, 0) > 0) - wallet.metrics_status = 'on' if metrics_ok else 'shame' - wallet.logs_status = 'on' if logs_ok else 'shame' + metrics_status = 'on' if metrics_ok else 'shame' + logs_status = 'on' if logs_ok else 'shame' + + if metrics_status == 'shame': + if wallet.metrics_status != 'shame' or not wallet.metrics_shame_started_at: + wallet.metrics_shame_started_at = now + else: + wallet.metrics_shame_started_at = None + + if logs_status == 'shame': + if wallet.logs_status != 'shame' or not wallet.logs_shame_started_at: + wallet.logs_shame_started_at = now + else: + wallet.logs_shame_started_at = None + + wallet.metrics_status = metrics_status + wallet.logs_status = logs_status wallet.last_grafana_check_at = now if metrics_ok and logs_ok: @@ -199,7 +221,13 @@ def sync_network(cls, network): ValidatorWallet.objects.bulk_update( wallets, - ['metrics_status', 'logs_status', 'last_grafana_check_at'], + [ + 'metrics_status', + 'logs_status', + 'last_grafana_check_at', + 'metrics_shame_started_at', + 'logs_shame_started_at', + ], ) logger.info( diff --git a/backend/validators/migrations/0013_validatorwallet_shame_started_at.py b/backend/validators/migrations/0013_validatorwallet_shame_started_at.py new file mode 100644 index 00000000..a26e02a1 --- /dev/null +++ b/backend/validators/migrations/0013_validatorwallet_shame_started_at.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('validators', '0012_validatorwallet_last_grafana_check_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='validatorwallet', + name='metrics_shame_started_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='validatorwallet', + name='logs_shame_started_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='validatorwallet', + name='version_shame_started_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/validators/models.py b/backend/validators/models.py index f2a9c356..fc94502c 100644 --- a/backend/validators/models.py +++ b/backend/validators/models.py @@ -62,6 +62,9 @@ class ValidatorWallet(BaseModel): max_length=10, choices=GRAFANA_STATUS_CHOICES, default='unknown', db_index=True ) last_grafana_check_at = models.DateTimeField(null=True, blank=True) + metrics_shame_started_at = models.DateTimeField(null=True, blank=True) + logs_shame_started_at = models.DateTimeField(null=True, blank=True) + version_shame_started_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['-created_at'] diff --git a/backend/validators/serializers.py b/backend/validators/serializers.py index 06823723..b8d5a39e 100644 --- a/backend/validators/serializers.py +++ b/backend/validators/serializers.py @@ -31,7 +31,7 @@ class Meta: read_only_fields = ['id', 'created_at', 'updated_at'] def get_operator_user(self, obj): - if obj.operator and obj.operator.user: + if obj.operator and obj.operator.user and obj.operator.user.visible: user = obj.operator.user return { 'id': user.id, @@ -82,11 +82,14 @@ class Meta: 'metrics_status', 'logs_status', 'last_grafana_check_at', + 'metrics_shame_started_at', + 'logs_shame_started_at', + 'version_shame_started_at', ] read_only_fields = fields def get_operator_user(self, obj): - if obj.operator and obj.operator.user: + if obj.operator and obj.operator.user and obj.operator.user.visible: user = obj.operator.user return { 'id': user.id, diff --git a/backend/validators/tests/test_grafana_service.py b/backend/validators/tests/test_grafana_service.py index 1701a6a4..522c01ce 100644 --- a/backend/validators/tests/test_grafana_service.py +++ b/backend/validators/tests/test_grafana_service.py @@ -7,12 +7,16 @@ filtering, cache invalidation, and operator identity surfacing. """ +from datetime import timedelta from unittest.mock import patch, MagicMock from django.test import TestCase from django.core.cache import cache +from django.utils import timezone from rest_framework.test import APIClient -from validators.models import ValidatorWallet +from contributions.node_upgrade.models import TargetNodeVersion +from users.models import User +from validators.models import Validator, ValidatorWallet from validators.grafana_service import GrafanaValidatorStatusService @@ -352,3 +356,169 @@ def test_stats_counts(self): self.assertEqual(stats['total'], 3) self.assertEqual(stats['on'], 1) # alpha-ok only self.assertEqual(stats['shame'], 2) # zelda-shame + mid-asimov + + def test_grouped_validator_reasons_across_networks(self): + user = User.objects.create_user( + email='grouped@example.com', + password='password', + name='Grouped Operator', + address='0xgroupedoperator0000000000000000000000000'[:42], + ) + validator = Validator.objects.create( + user=user, + node_version_asimov='2.0.0', + node_version_bradbury='2.0.0', + ) + ValidatorWallet.objects.create( + address='0xgroupedasimov0000000000000000000000000000'[:42], + network='asimov', + operator=validator, + operator_address=user.address, + status='active', + moniker='grouped-asimov', + metrics_status='shame', + logs_status='on', + ) + ValidatorWallet.objects.create( + address='0xgroupedbradbury00000000000000000000000000'[:42], + network='bradbury', + operator=validator, + operator_address=user.address, + status='active', + moniker='grouped-bradbury', + metrics_status='on', + logs_status='shame', + ) + + response = self.client.get('/api/v1/validators/wallets/wall-of-shame/') + grouped = next( + item for item in response.data['validators'] + if item['operator_user'] and item['operator_user']['id'] == user.id + ) + + self.assertEqual(grouped['status'], 'shame') + self.assertEqual(len(grouped['networks']), 2) + self.assertEqual( + {(reason['network'], reason['type']) for reason in grouped['shame_reasons']}, + {('asimov', 'metrics'), ('bradbury', 'logs')}, + ) + + def test_hidden_operator_identity_is_not_exposed(self): + """A hidden (visible=False) operator must not have their profile + identity surfaced on the public Wall of Shame, but the validator wallet + itself should still appear (falling back to the on-chain address).""" + user = User.objects.create_user( + email='hidden-operator@example.com', + password='password', + name='Hidden Operator', + address='0xhiddenoperator00000000000000000000000000'[:42], + visible=False, + ) + validator = Validator.objects.create(user=user) + wallet = ValidatorWallet.objects.create( + address='0xhiddenwallet0000000000000000000000000000'[:42], + network='asimov', + operator=validator, + operator_address=user.address, + status='active', + moniker='hidden-asimov', + metrics_status='shame', + logs_status='on', + ) + + response = self.client.get('/api/v1/validators/wallets/wall-of-shame/') + + # operator_user must be withheld in both payloads for the hidden user. + for entry in response.data['wallets']: + if entry['address'] == wallet.address: + self.assertIsNone(entry['operator_user']) + + group = next( + item for item in response.data['validators'] + if item['operator_address'] == user.address + ) + self.assertIsNone(group['operator_user']) + # The validator still appears (so a misbehaving node isn't hidden), + # identified only by its on-chain operator address. + self.assertEqual(group['status'], 'shame') + self.assertEqual(group['operator_address'], user.address) + + def test_outdated_version_is_warning_during_grace_period(self): + TargetNodeVersion.objects.create( + version='2.0.0', + network='asimov', + target_date=timezone.now() - timedelta(days=2), + is_active=True, + ) + user = User.objects.create_user( + email='grace@example.com', + password='password', + name='Grace Operator', + address='0xgraceoperator00000000000000000000000000'[:42], + ) + validator = Validator.objects.create(user=user, node_version_asimov='1.0.0') + wallet = ValidatorWallet.objects.create( + address='0xgracewallet0000000000000000000000000000'[:42], + network='asimov', + operator=validator, + operator_address=user.address, + status='active', + moniker='grace-asimov', + metrics_status='on', + logs_status='on', + ) + + response = self.client.get('/api/v1/validators/wallets/wall-of-shame/') + grouped = next( + item for item in response.data['validators'] + if item['operator_user'] and item['operator_user']['id'] == user.id + ) + reason = grouped['shame_reasons'][0] + + self.assertEqual(grouped['status'], 'warning') + self.assertEqual(reason['type'], 'version') + self.assertEqual(reason['status'], 'warning') + wallet.refresh_from_db() + self.assertIsNone(wallet.version_shame_started_at) + + def test_outdated_version_becomes_persisted_shame_after_grace_period(self): + target = TargetNodeVersion.objects.create( + version='2.0.0', + network='asimov', + target_date=timezone.now() - timedelta(days=5), + is_active=True, + ) + user = User.objects.create_user( + email='outdated@example.com', + password='password', + name='Outdated Operator', + address='0xoutdatedoperator000000000000000000000000'[:42], + ) + validator = Validator.objects.create(user=user, node_version_asimov='1.0.0') + wallet = ValidatorWallet.objects.create( + address='0xoutdatedwallet00000000000000000000000000'[:42], + network='asimov', + operator=validator, + operator_address=user.address, + status='active', + moniker='outdated-asimov', + metrics_status='on', + logs_status='on', + ) + + response = self.client.get('/api/v1/validators/wallets/wall-of-shame/') + grouped = next( + item for item in response.data['validators'] + if item['operator_user'] and item['operator_user']['id'] == user.id + ) + reason = grouped['shame_reasons'][0] + + self.assertEqual(grouped['status'], 'shame') + self.assertEqual(reason['type'], 'version') + self.assertEqual(reason['status'], 'shame') + self.assertGreaterEqual(reason['days_in_shame'], 1) + wallet.refresh_from_db() + self.assertEqual( + wallet.version_shame_started_at.replace(microsecond=0), + (target.target_date + timedelta(days=3)).replace(microsecond=0), + ) diff --git a/backend/validators/views.py b/backend/validators/views.py index effe8141..763c8612 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -1,6 +1,7 @@ import logging import re import uuid +from datetime import timedelta from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response @@ -242,6 +243,7 @@ class ValidatorWalletViewSet(viewsets.ReadOnlyModelViewSet): SYNC_LOCK_STALE_AFTER_SECONDS = 1800 SYNC_LOCK_HEARTBEAT_INTERVAL_SECONDS = 60 WALL_OF_SHAME_CACHE_TTL_SECONDS = 60 + VERSION_SHAME_GRACE_DAYS = 3 def get_queryset(self): """ @@ -614,12 +616,270 @@ def _invalidate_wall_of_shame_cache(cls): for network in settings.TESTNET_NETWORKS.keys(): cache.delete(cls._wall_of_shame_cache_key(network)) + @staticmethod + def _days_since(started_at, now): + if not started_at: + return None + return max(0, (now - started_at).days) + + @staticmethod + def _operator_user_payload(wallet): + if wallet.operator and wallet.operator.user and wallet.operator.user.visible: + user = wallet.operator.user + return { + 'id': user.id, + 'name': user.name, + 'address': user.address, + 'profile_image_url': user.profile_image_url, + 'visible': user.visible, + } + return None + + @classmethod + def _version_context(cls, wallet, target, now): + field_name = f'node_version_{wallet.network}' + node_version = getattr(wallet.operator, field_name, None) if wallet.operator else None + target_version = target.version if target else None + target_date = target.target_date if target else None + + context = { + 'status': 'unknown' if not target else 'on', + 'node_version': node_version, + 'target_version': target_version, + 'target_date': target_date, + 'target_elapsed_days': None, + 'grace_days': cls.VERSION_SHAME_GRACE_DAYS, + 'grace_days_remaining': None, + 'shame_started_at': None, + } + if not target or not target_date or target_date > now: + return context + + context['target_elapsed_days'] = max(0, (now - target_date).days) + matches_target = bool( + wallet.operator + and node_version + and wallet.operator.version_matches_or_higher(target.version, node_version=node_version) + ) + if matches_target: + context['status'] = 'on' + return context + + if context['target_elapsed_days'] <= cls.VERSION_SHAME_GRACE_DAYS: + context['status'] = 'warning' + context['grace_days_remaining'] = max( + 0, + cls.VERSION_SHAME_GRACE_DAYS - context['target_elapsed_days'], + ) + return context + + context['status'] = 'shame' + context['shame_started_at'] = target_date + timedelta(days=cls.VERSION_SHAME_GRACE_DAYS) + return context + + @classmethod + def _sync_shame_started_at(cls, wallets, targets, now): + changed = [] + for wallet in wallets: + update_wallet = False + + if wallet.metrics_status == 'shame': + if not wallet.metrics_shame_started_at: + wallet.metrics_shame_started_at = now + update_wallet = True + elif wallet.metrics_shame_started_at: + wallet.metrics_shame_started_at = None + update_wallet = True + + if wallet.logs_status == 'shame': + if not wallet.logs_shame_started_at: + wallet.logs_shame_started_at = now + update_wallet = True + elif wallet.logs_shame_started_at: + wallet.logs_shame_started_at = None + update_wallet = True + + version_context = cls._version_context(wallet, targets.get(wallet.network), now) + desired_version_started_at = ( + version_context['shame_started_at'] + if version_context['status'] == 'shame' + else None + ) + if desired_version_started_at: + if ( + not wallet.version_shame_started_at + or wallet.version_shame_started_at < desired_version_started_at + ): + wallet.version_shame_started_at = desired_version_started_at + update_wallet = True + elif wallet.version_shame_started_at: + wallet.version_shame_started_at = None + update_wallet = True + + if update_wallet: + changed.append(wallet) + + if changed: + ValidatorWallet.objects.bulk_update( + changed, + [ + 'metrics_shame_started_at', + 'logs_shame_started_at', + 'version_shame_started_at', + ], + ) + + @classmethod + def _reason(cls, reason_type, label, started_at, now, reason_status='shame', **extra): + return { + 'type': reason_type, + 'label': label, + 'status': reason_status, + 'started_at': started_at, + 'days_in_shame': cls._days_since(started_at, now), + **extra, + } + + @classmethod + def _wallet_reasons(cls, wallet, target, now): + reasons = [] + if wallet.metrics_status == 'shame': + reasons.append(cls._reason( + 'metrics', + 'no metrics', + wallet.metrics_shame_started_at, + now, + )) + if wallet.logs_status == 'shame': + reasons.append(cls._reason( + 'logs', + 'no logs', + wallet.logs_shame_started_at, + now, + )) + + version_context = cls._version_context(wallet, target, now) + if version_context['status'] in ('warning', 'shame'): + started_at = ( + wallet.version_shame_started_at + if version_context['status'] == 'shame' + else None + ) + reasons.append(cls._reason( + 'version', + 'outdated version', + started_at, + now, + reason_status=version_context['status'], + node_version=version_context['node_version'], + target_version=version_context['target_version'], + target_elapsed_days=version_context['target_elapsed_days'], + grace_days_remaining=version_context['grace_days_remaining'], + )) + return reasons + + @classmethod + def _build_validator_groups(cls, wallets, targets, now): + groups = {} + + for wallet in wallets: + operator_key = ( + f'validator:{wallet.operator_id}' + if wallet.operator_id + else f'operator:{(wallet.operator_address or "").lower()}' + ) + operator_user = cls._operator_user_payload(wallet) + group = groups.setdefault(operator_key, { + 'id': operator_key, + 'operator_address': wallet.operator_address, + 'operator_user': operator_user, + 'name': ( + (operator_user or {}).get('name') + or wallet.moniker + or wallet.operator_address + ), + 'logo_uri': wallet.logo_uri, + 'networks': [], + 'shame_reasons': [], + 'status': 'on', + }) + if not group['logo_uri'] and wallet.logo_uri: + group['logo_uri'] = wallet.logo_uri + if not group['operator_user'] and operator_user: + group['operator_user'] = operator_user + + target = targets.get(wallet.network) + version_context = cls._version_context(wallet, target, now) + reasons = cls._wallet_reasons(wallet, target, now) + has_unknown = wallet.metrics_status == 'unknown' or wallet.logs_status == 'unknown' + network_status = 'on' + if any(reason['status'] == 'shame' for reason in reasons): + network_status = 'shame' + elif any(reason['status'] == 'warning' for reason in reasons): + network_status = 'warning' + elif has_unknown: + network_status = 'unknown' + + for reason in reasons: + group['shame_reasons'].append({ + 'network': wallet.network, + **reason, + }) + + group['networks'].append({ + 'network': wallet.network, + 'wallet_id': wallet.id, + 'address': wallet.address, + 'moniker': wallet.moniker, + 'logo_uri': wallet.logo_uri, + 'metrics_status': wallet.metrics_status, + 'logs_status': wallet.logs_status, + 'version_status': version_context['status'], + 'node_version': version_context['node_version'], + 'target_version': version_context['target_version'], + 'status': network_status, + 'reasons': reasons, + }) + + priority = {'shame': 0, 'warning': 1, 'unknown': 2, 'on': 3} + network_order = { + network: index for index, network in enumerate(settings.TESTNET_NETWORKS.keys()) + } + + for group in groups.values(): + statuses = [network['status'] for network in group['networks']] + if 'shame' in statuses: + group['status'] = 'shame' + elif 'warning' in statuses: + group['status'] = 'warning' + elif 'unknown' in statuses: + group['status'] = 'unknown' + else: + group['status'] = 'on' + group['networks'].sort(key=lambda item: network_order.get(item['network'], 99)) + group['shame_reasons'].sort( + key=lambda item: ( + network_order.get(item['network'], 99), + priority.get(item['status'], 99), + item['label'], + ) + ) + + return sorted( + groups.values(), + key=lambda group: ( + priority.get(group['status'], 99), + (group['name'] or '').lower(), + group['operator_address'] or '', + ) + ) + @action(detail=False, methods=['get'], url_path='wall-of-shame') def wall_of_shame(self, request): """ - Public Wall of Shame: all active validator wallets with their latest - Grafana metrics/logs status. Sorted with SHAME rows first, then by - moniker. Cached for 60s. Optional ?network=asimov|bradbury filter. + Public Wall of Shame: active validators grouped by operator with their + latest per-network metrics/logs/version reasons. Cached for 60s. + Optional ?network=asimov|bradbury filter. """ network = request.query_params.get('network', '').strip().lower() or None if network and network not in settings.TESTNET_NETWORKS: @@ -641,8 +901,7 @@ def wall_of_shame(self, request): if network: queryset = queryset.filter(network=network) - # SHAME rows first (metrics_status='shame' OR logs_status='shame'), - # then alphabetical by moniker (case-insensitive), then by address as a stable tiebreaker. + # Keep wallet ordering stable for the compatibility `wallets` payload. from django.db.models import Case, IntegerField, Value, When from django.db.models.functions import Lower @@ -661,28 +920,36 @@ def wall_of_shame(self, request): # Pick the latest Grafana check timestamp across all returned rows so # the UI can show staleness of the data itself (not per-validator). wallets = list(queryset) + now = timezone.now() + + from contributions.node_upgrade.models import TargetNodeVersion + targets = { + network_name: TargetNodeVersion.get_active(network=network_name) + for network_name in settings.TESTNET_NETWORKS.keys() + } + self._sync_shame_started_at(wallets, targets, now) + last_check = max( (w.last_grafana_check_at for w in wallets if w.last_grafana_check_at is not None), default=None, ) serializer = WallOfShameSerializer(wallets, many=True) - on_count = sum( - 1 for w in wallets - if w.metrics_status == 'on' and w.logs_status == 'on' - ) - shame_count = sum( - 1 for w in wallets - if w.metrics_status == 'shame' or w.logs_status == 'shame' - ) + validators = self._build_validator_groups(wallets, targets, now) + on_count = sum(1 for validator in validators if validator['status'] == 'on') + shame_count = sum(1 for validator in validators if validator['status'] == 'shame') + warning_count = sum(1 for validator in validators if validator['status'] == 'warning') + unknown_count = sum(1 for validator in validators if validator['status'] == 'unknown') payload = { 'wallets': serializer.data, + 'validators': validators, 'stats': { - 'total': len(wallets), + 'total': len(validators), 'on': on_count, 'shame': shame_count, - 'unknown': len(wallets) - on_count - shame_count, + 'warning': warning_count, + 'unknown': unknown_count, }, 'last_grafana_check_at': last_check, 'network': network or 'all', diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 860fd108..6ddfa3f1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -10,6 +10,7 @@ import { currentCategory, detectCategoryFromRoute } from './stores/category.js'; import { location } from 'svelte-spa-router'; import { resetPageMeta } from './lib/meta.js'; + import { authState, verifyAuth } from './lib/auth.js'; // Early OAuth result detection — runs before routes mount. // Backend redirects here with ?oauth_platform=X&oauth_verified=true/false&oauth_error=... @@ -97,6 +98,7 @@ import ValidatorWaitlist from './routes/ValidatorWaitlist.svelte'; import Waitlist from './routes/Waitlist.svelte'; import WaitlistParticipants from './routes/WaitlistParticipants.svelte'; + import WallOfShame from './routes/WallOfShame.svelte'; import TermsOfUse from './routes/TermsOfUse.svelte'; import PrivacyPolicy from './routes/PrivacyPolicy.svelte'; @@ -121,61 +123,92 @@ import GlobalDashboard from './components/GlobalDashboard.svelte'; import SystemAlerts from './components/portal/SystemAlerts.svelte'; + async function requireAuthForRoute({ location, querystring }) { + const state = authState.get(); + const isAuthenticated = state.isAuthenticated || await verifyAuth(); + + if (isAuthenticated) { + return true; + } + + sessionStorage.setItem( + 'redirectAfterLogin', + `${location || '/'}${querystring ? `?${querystring}` : ''}` + ); + + push('/'); + + setTimeout(() => { + const authButton = document.querySelector('[data-auth-button]'); + if (authButton) { + authButton.click(); + } + }, 0); + + return false; + } + + const protectedRoute = (component) => wrap({ + component, + conditions: [requireAuthForRoute], + }); + // Define routes const routes = { // Global/Testnet Asimov routes // Overview and Testnet Asimov routes '/': Overview, - '/testnets': GlobalDashboard, + '/testnets': protectedRoute(GlobalDashboard), '/how-it-works': HowItWorks, - '/contributions': Contributions, - '/all-contributions': AllContributions, - '/leaderboard': Leaderboard, + '/contributions': protectedRoute(Contributions), + '/all-contributions': protectedRoute(AllContributions), + '/leaderboard': protectedRoute(Leaderboard), '/participants': Validators, - '/referrals': Referrals, - '/community': Dashboard, - '/community/contributions': Contributions, - '/community/all-contributions': AllContributions, + '/referrals': protectedRoute(Referrals), + '/community': protectedRoute(Dashboard), + '/community/contributions': protectedRoute(Contributions), + '/community/all-contributions': protectedRoute(AllContributions), '/community/referrals': LegacyReferralRedirect, - '/community/leaderboard': Leaderboard, + '/community/leaderboard': protectedRoute(Leaderboard), '/community/poaps': CommunityPoaps, '/community/poaps/recover': PoapRecovery, '/community/poaps/:slug': PoapDetail, - '/community/contribution/:id': ContributionPreview, + '/community/contribution/:id': protectedRoute(ContributionPreview), '/claim/poap/:token': PoapClaim, '/hackathon': Hackathon, '/hackathon-winners': HackathonWinners, '/referral-program': ReferralProgram, // Builders routes - '/builders': Dashboard, - '/builders/contributions': Contributions, - '/builders/all-contributions': AllContributions, - '/builders/leaderboard': Leaderboard, + '/builders': protectedRoute(Dashboard), + '/builders/contributions': protectedRoute(Contributions), + '/builders/all-contributions': protectedRoute(AllContributions), + '/builders/leaderboard': protectedRoute(Leaderboard), '/builders/resources': Resources, - '/builders/projects/:slug/edit': ProjectPageEditor, + '/builders/projects/:slug/edit': protectedRoute(ProjectPageEditor), '/builders/projects/:slug': ProjectDetail, '/builders/startup-requests/:id': StartupRequestDetail, // Validators routes - '/validators': Dashboard, - '/validators/contributions': Contributions, - '/validators/all-contributions': AllContributions, - '/validators/leaderboard': Leaderboard, + '/validators': protectedRoute(Dashboard), + '/validators/contributions': protectedRoute(Contributions), + '/validators/all-contributions': protectedRoute(AllContributions), + '/validators/leaderboard': protectedRoute(Leaderboard), '/validators/participants': Validators, - '/validators/waitlist': Waitlist, - '/validators/waitlist/participants': WaitlistParticipants, + '/validators/wall-of-shame': WallOfShame, + '/validators/waitlist': protectedRoute(Waitlist), + '/validators/waitlist/participants': protectedRoute(WaitlistParticipants), '/validators/waitlist/join': ValidatorWaitlist, // Shared routes - '/participant/:address': Profile, - '/contribution/:id': ContributionPreview, - '/builders/contribution/:id': ContributionPreview, - '/validators/contribution/:id': ContributionPreview, - '/contribution-type/:id': ContributionTypeDetail, - '/mission/:id': MissionDetail, + '/participant/:address': protectedRoute(Profile), + '/contribution/:id': protectedRoute(ContributionPreview), + '/builders/contribution/:id': protectedRoute(ContributionPreview), + '/validators/contribution/:id': protectedRoute(ContributionPreview), + '/contribution-type/:id': protectedRoute(ContributionTypeDetail), + '/mission/:id': protectedRoute(MissionDetail), '/badge/:id': BadgeDetail, '/submit-contribution': SubmitContribution, '/my-submissions': MySubmissions, diff --git a/frontend/src/components/Navbar.svelte b/frontend/src/components/Navbar.svelte index 7fb3d562..085b18f7 100644 --- a/frontend/src/components/Navbar.svelte +++ b/frontend/src/components/Navbar.svelte @@ -57,7 +57,9 @@- ON: {stats.on} | SHAME: {stats.shame} | Unknown: {stats.unknown} + ON: {stats.on} | SHAME: {stats.shame} | Warning: {stats.warning || 0}
@@ -131,80 +224,36 @@
| - Metrics - | -- Logs - | -- Network - | Validator | Operator | ++ Shame + |
|---|---|---|---|---|---|
| - - {statusText(wallet.metrics_status)} - - | -- - {statusText(wallet.logs_status)} - - | -- - {networkLabel(wallet.network)} - - |
- {#if wallet.logo_uri}
+ {#if validator.logo_uri}
-
- {:else if wallet.moniker}
+ {:else if validator.name}
- {wallet.moniker.substring(0, 2).toUpperCase()}
+ {validator.name.substring(0, 2).toUpperCase()}
{/if}
- {wallet.moniker || '(unnamed)'}
-
- {truncateAddress(wallet.address)}
-
- {#if wallet.explorer_url}
-
-
-
+ {validator.name || '(unnamed)'}
+
@@ -212,27 +261,32 @@
+ {#if validatorMonikers(validator)}
+ {validatorMonikers(validator)}
{/if}
|
- {#if wallet.operator_user}
+ {#if validator.operator_user}
|
+
+
+
+
+
+ {statusText(validator.status)}
+
+
+
+ {#if validator.shame_reasons?.length}
+
+ {#each shameEntries(validator) as entry, index}
+
+ {entry.text}
+ {#if entry.details.length}
+ {entry.details.join(' / ')}
+ {/if}
+ {#if entry.status === 'shame' && entry.days_in_shame !== null && entry.days_in_shame !== undefined}
+
+ {formatDays(entry.days_in_shame)}
+
+ {:else if entry.status === 'warning'}
+
+ grace
+
+ {/if}
+
+ {#if index < shameEntries(validator).length - 1}
+ ,
+ {/if}
+ {/each}
+
+ {:else}
+ No shame reasons.
+ {/if}
+ |