From cfbc59b902e54159cb7d7714ae99718cd9bcbcb5 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sat, 30 May 2026 16:47:20 +0200 Subject: [PATCH 1/6] Remove session key from auth responses --- backend/ethereum_auth/tests.py | 37 ++++++++++++++++++++++++++++++++++ backend/ethereum_auth/views.py | 6 ++---- 2 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 backend/ethereum_auth/tests.py 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..a5b2a6f8 100644 --- a/backend/ethereum_auth/views.py +++ b/backend/ethereum_auth/views.py @@ -160,7 +160,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, @@ -192,8 +191,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 +229,4 @@ def refresh_session(request): return Response( {'error': 'Not authenticated.'}, status=status.HTTP_401_UNAUTHORIZED - ) \ No newline at end of file + ) From 425e15d7b4071a083bcf8ab904b7ff0e72b5ac04 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 1 Jun 2026 16:16:35 +0200 Subject: [PATCH 2/6] Restore validator Wall of Shame route --- frontend/src/App.svelte | 2 ++ frontend/src/components/Sidebar.svelte | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 860fd108..3095381b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -97,6 +97,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'; @@ -165,6 +166,7 @@ '/validators/all-contributions': AllContributions, '/validators/leaderboard': Leaderboard, '/validators/participants': Validators, + '/validators/wall-of-shame': WallOfShame, '/validators/waitlist': Waitlist, '/validators/waitlist/participants': WaitlistParticipants, '/validators/waitlist/join': ValidatorWaitlist, diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index dc3af8fa..19d78260 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -314,6 +314,15 @@ > Participants + { e.preventDefault(); navigate('/validators/wall-of-shame'); }} + class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' + }" + > + Wall of Shame + { e.preventDefault(); navigate('/validators/waitlist'); }} From 420f6e72877b2977b8d4efea4bd2c8a524f93c21 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 2 Jun 2026 17:09:44 +0200 Subject: [PATCH 3/6] Update wall of shame reasons (#727) --- .../migrations/0037_seed_featured_content.py | 124 ++++--- .../0051_zero_validator_waitlist_points.py | 3 + .../0002_ensure_project_participants_table.py | 26 ++ backend/validators/grafana_service.py | 36 ++- .../0013_validatorwallet_shame_started_at.py | 26 ++ backend/validators/models.py | 3 + backend/validators/serializers.py | 3 + .../validators/tests/test_grafana_service.py | 132 +++++++- backend/validators/views.py | 297 ++++++++++++++++- frontend/src/routes/WallOfShame.svelte | 306 +++++++++++------- package-lock.json | 2 +- 11 files changed, 783 insertions(+), 175 deletions(-) create mode 100644 backend/projects/migrations/0002_ensure_project_participants_table.py create mode 100644 backend/validators/migrations/0013_validatorwallet_shame_started_at.py 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/projects/migrations/0002_ensure_project_participants_table.py b/backend/projects/migrations/0002_ensure_project_participants_table.py new file mode 100644 index 00000000..28432200 --- /dev/null +++ b/backend/projects/migrations/0002_ensure_project_participants_table.py @@ -0,0 +1,26 @@ +from django.db import migrations + + +def ensure_project_participants_table(apps, schema_editor): + 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/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..1157d20f 100644 --- a/backend/validators/serializers.py +++ b/backend/validators/serializers.py @@ -82,6 +82,9 @@ 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 diff --git a/backend/validators/tests/test_grafana_service.py b/backend/validators/tests/test_grafana_service.py index 1701a6a4..41220077 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,129 @@ 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_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..d5417ec1 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: + 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/routes/WallOfShame.svelte b/frontend/src/routes/WallOfShame.svelte index 7d5ea73d..d5f2b7a9 100644 --- a/frontend/src/routes/WallOfShame.svelte +++ b/frontend/src/routes/WallOfShame.svelte @@ -4,15 +4,50 @@ import Avatar from '../components/Avatar.svelte'; import { showSuccess } from '../lib/toastStore'; - let wallets = $state([]); - let stats = $state({ total: 0, on: 0, shame: 0, unknown: 0 }); + /** + * @typedef {Object} ShameReason + * @property {string} network + * @property {string} type + * @property {string} label + * @property {string} status + * @property {number | null | undefined} days_in_shame + * @property {string | null | undefined} node_version + * @property {string | null | undefined} target_version + */ + /** + * @typedef {Object} ValidatorNetwork + * @property {string} network + * @property {string | null | undefined} moniker + */ + /** + * @typedef {Object} ValidatorRow + * @property {string} status + * @property {string | null | undefined} name + * @property {string | null | undefined} logo_uri + * @property {string | null | undefined} operator_address + * @property {any} operator_user + * @property {ValidatorNetwork[]} networks + * @property {ShameReason[]} shame_reasons + */ + /** + * @typedef {Object} ShameEntry + * @property {string} network + * @property {string} text + * @property {string} status + * @property {number | null | undefined} days_in_shame + * @property {string[]} details + */ + + /** @type {ValidatorRow[]} */ + let validators = $state([]); + let stats = $state({ total: 0, on: 0, shame: 0, warning: 0 }); + /** @type {string | null} */ let lastCheckAt = $state(null); let loading = $state(true); + /** @type {string | null} */ let error = $state(null); - let selectedNetwork = $state('all'); $effect(() => { - const _network = selectedNetwork; fetchWallOfShame(); }); @@ -20,50 +55,129 @@ try { loading = true; error = null; - const params = {}; - if (selectedNetwork !== 'all') { - params.network = selectedNetwork; - } - const response = await validatorsAPI.getWallOfShame(params); - wallets = response.data?.wallets || []; - stats = response.data?.stats || { total: 0, on: 0, shame: 0, unknown: 0 }; + const response = await validatorsAPI.getWallOfShame(); + /** @type {ValidatorRow[]} */ + const rows = response.data?.validators || []; + validators = rows.filter((validator) => validator.status !== 'unknown'); + const responseStats = response.data?.stats || {}; + stats = { + total: validators.length, + on: responseStats.on || 0, + shame: responseStats.shame || 0, + warning: responseStats.warning || 0, + }; lastCheckAt = response.data?.last_grafana_check_at || null; loading = false; } catch (err) { - error = err.message || 'Failed to load Wall of Shame'; + error = err instanceof Error ? err.message : 'Failed to load Wall of Shame'; loading = false; } } + /** @param {string | null | undefined} address */ function truncateAddress(address) { if (!address) return ''; return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; } - function statusClass(value) { + /** @param {string} value */ + function rowStatusClass(value) { if (value === 'on') return 'bg-green-100 text-green-800'; if (value === 'shame') return 'bg-red-100 text-red-800'; - return 'bg-gray-100 text-gray-500'; + if (value === 'warning') return 'bg-amber-100 text-amber-800'; + return 'bg-gray-100 text-gray-600'; } + /** @param {string} value */ function statusText(value) { if (value === 'on') return 'ON'; if (value === 'shame') return 'SHAME'; - return '—'; + if (value === 'warning') return 'WARNING'; + return ''; + } + + /** @param {ShameEntry} entry */ + function entryClass(entry) { + if (entry.status === 'shame') return 'bg-red-50 text-red-800 ring-red-200'; + if (entry.status === 'warning') return 'bg-amber-50 text-amber-800 ring-amber-200'; + return 'bg-gray-50 text-gray-700 ring-gray-200'; + } + + /** @param {number} days */ + function daysClass(days) { + if (days >= 7) return 'bg-red-200 text-red-950'; + if (days >= 3) return 'bg-orange-200 text-orange-950'; + return 'bg-amber-200 text-amber-950'; + } + + /** @param {number | null | undefined} days */ + function formatDays(days) { + if (days === null || days === undefined) return ''; + if (days === 0) return 'today'; + if (days === 1) return '1d'; + return `${days}d`; } - function networkBadgeClass(network) { - if (network === 'asimov') return 'bg-blue-100 text-blue-800'; - if (network === 'bradbury') return 'bg-purple-100 text-purple-800'; - return 'bg-gray-100 text-gray-800'; + /** @param {ShameReason} reason */ + function reasonDetail(reason) { + if (reason.type !== 'version') return ''; + const current = reason.node_version || 'missing'; + const target = reason.target_version || 'target unknown'; + return `${current} -> ${target}`; } - function networkLabel(network) { - if (network === 'asimov') return 'Asimov'; - if (network === 'bradbury') return 'Bradbury'; - return network || 'Unknown'; + /** @param {string} network */ + function networkText(network) { + if (network === 'asimov') return 'asimov'; + if (network === 'bradbury') return 'bradbury'; + return network || 'unknown'; } + /** @param {ValidatorRow} validator */ + function shameEntries(validator) { + /** @type {Array<{ network: string, reasons: ShameReason[] }>} */ + const grouped = []; + for (const reason of validator.shame_reasons || []) { + let group = grouped.find((item) => item.network === reason.network); + if (!group) { + group = { network: reason.network, reasons: [] }; + grouped.push(group); + } + group.reasons.push(reason); + } + + /** @type {ShameEntry[]} */ + const entries = []; + for (const group of grouped) { + const labels = group.reasons.map((reason) => reason.label); + const details = group.reasons + .map((reason) => reasonDetail(reason)) + .filter(Boolean); + const shameReasons = group.reasons.filter((reason) => reason.status === 'shame'); + const days = shameReasons + .map((reason) => reason.days_in_shame) + .filter((days) => days !== null && days !== undefined); + entries.push({ + network: group.network, + text: `${networkText(group.network)}: ${labels.join(' + ')}`, + status: shameReasons.length ? 'shame' : group.reasons[0]?.status || 'unknown', + days_in_shame: days.length ? Math.max(...days) : null, + details, + }); + } + return entries; + } + + /** @param {ValidatorRow} validator */ + function validatorMonikers(validator) { + const monikers = (validator.networks || []) + /** @param {ValidatorNetwork} network */ + .map((network) => network.moniker) + .filter(Boolean); + return [...new Set(monikers)].join(' / '); + } + + /** @param {string | null | undefined} iso */ function formatRelative(iso) { if (!iso) return 'never'; const ts = new Date(iso).getTime(); @@ -91,27 +205,6 @@ {error} {:else} -
- - - -
-
@@ -119,7 +212,7 @@ Active Validators ({stats.total})

- ON: {stats.on} | SHAME: {stats.shame} | Unknown: {stats.unknown} + ON: {stats.on} | SHAME: {stats.shame} | Warning: {stats.warning || 0}

@@ -131,80 +224,36 @@ - - - + - {#each wallets as wallet, i} + {#each validators as validator, i} - - - + {/each}
- 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}
- {wallet.moniker + {validator.name
- {: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)'} +
+ {#if validatorMonikers(validator)} + {validatorMonikers(validator)} {/if}
@@ -212,27 +261,28 @@
- {#if wallet.operator_user} + {#if validator.operator_user} { e.preventDefault(); push(`/participant/${wallet.operator_user.address}`); }} + href={`/participant/${validator.operator_user.address}`} + onclick={(e) => { e.preventDefault(); push(`/participant/${validator.operator_user.address}`); }} class="text-primary-600 hover:text-primary-800" > - {wallet.operator_user.name || truncateAddress(wallet.operator_user.address)} + {validator.operator_user.name || truncateAddress(validator.operator_user.address)} {:else} - {truncateAddress(wallet.operator_address)} + {truncateAddress(validator.operator_address)} {/if}
+
+
+ + {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} +
+

- {#if wallets.length === 0} + {#if validators.length === 0}
- No active validators found. Data will appear after the next Grafana sync. + No validators to show.
{/if}
diff --git a/package-lock.json b/package-lock.json index 08f557ae..738a2abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "denver", + "name": "muscat", "lockfileVersion": 3, "requires": true, "packages": { From cf3f6f33f0005d4c4a6eadcc0d72717d05998235 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 3 Jun 2026 15:01:21 +0200 Subject: [PATCH 4/6] Restrict sensitive public API endpoints (#728) --- backend/CLAUDE.md | 32 +++---- backend/api/metrics_views.py | 6 +- .../tests/test_highlights_api.py | 16 ++++ .../tests/test_is_submittable.py | 24 +++++- .../tests/test_public_explorer_filters.py | 14 +++ .../tests/test_submission_limits.py | 1 + backend/contributions/views.py | 20 ++--- backend/leaderboard/tests/test_stats.py | 13 +++ backend/leaderboard/views.py | 4 +- backend/poaps/tests/test_poaps.py | 12 +++ backend/poaps/views.py | 7 +- backend/tally/settings.py | 2 +- backend/users/tests/test_email_security.py | 76 ++++++++--------- backend/users/views.py | 4 +- frontend/src/App.svelte | 85 +++++++++++++------ frontend/src/components/Navbar.svelte | 4 +- frontend/src/routes/Metrics.svelte | 36 ++++++-- frontend/src/routes/Overview.svelte | 15 ++-- package-lock.json | 2 +- 19 files changed, 253 insertions(+), 120 deletions(-) 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..fa9ecacb 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,6 +19,7 @@ class ActiveValidatorsView(APIView): Get active validators based on their first uptime contribution. Returns data points showing validator activation over time with continuous dates. """ + permission_classes = [permissions.AllowAny] def get(self, request): from django.db.models.functions import TruncDate @@ -91,6 +92,7 @@ class ContributionTypesStatsView(APIView): """ Get time series data showing how many contribution types have been assigned on each date. """ + permission_classes = [permissions.AllowAny] def get(self, request): from django.db.models.functions import TruncDate @@ -169,6 +171,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 +310,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/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/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..cff577d2 100644 --- a/backend/poaps/tests/test_poaps.py +++ b/backend/poaps/tests/test_poaps.py @@ -308,6 +308,18 @@ 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_are_public(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.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..56114a2c 100644 --- a/backend/poaps/views.py +++ b/backend/poaps/views.py @@ -322,7 +322,12 @@ 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', + permission_classes=[permissions.AllowAny], + ) def poaps(self, request, address=None): from django.shortcuts import get_object_or_404 from users.models import User diff --git a/backend/tally/settings.py b/backend/tally/settings.py index 4f452760..a3559e62 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', 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/frontend/src/App.svelte b/frontend/src/App.svelte index 3095381b..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=... @@ -122,62 +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/wall-of-shame': WallOfShame, - '/validators/waitlist': Waitlist, - '/validators/waitlist/participants': WaitlistParticipants, + '/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 @@ diff --git a/frontend/src/routes/Overview.svelte b/frontend/src/routes/Overview.svelte index bec41fd3..58a03092 100644 --- a/frontend/src/routes/Overview.svelte +++ b/frontend/src/routes/Overview.svelte @@ -8,17 +8,22 @@ import PortalHighlights from '../components/portal/PortalHighlights.svelte'; import NewestMembers from '../components/portal/NewestMembers.svelte'; import MiniLeaderboard from '../components/portal/MiniLeaderboard.svelte'; + import { authState } from '../lib/auth.js';
- - + {#if $authState.isAuthenticated} + + + {/if} - - - + {#if $authState.isAuthenticated} + + + + {/if}
diff --git a/package-lock.json b/package-lock.json index 738a2abc..e4909343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "muscat", + "name": "riga-v1", "lockfileVersion": 3, "requires": true, "packages": { From 20e146e8f1cd3db437e8cf2d526dc055192ad895 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 4 Jun 2026 15:35:05 +0200 Subject: [PATCH 5/6] Hide non-visible operators from public validator endpoints (#730) * Hide non-visible operators from public validator endpoints The public Wall of Shame and validator wallet endpoints surfaced the linked operator's profile identity (name, address, id, avatar) even for users who set their profile to non-visible, which contradicted the rest of the API where hidden users are not enumerable. Operator identity is now withheld whenever the linked user is not visible; the validator still appears, identified only by its on-chain operator address, so a misbehaving node cannot disappear by toggling visibility. Two production safeguards are also tightened: session and CSRF cookies are marked Secure outside DEBUG, and the wallet login endpoint no longer echoes raw exception details to clients in production. ## Claude Implementation Notes - backend/validators/serializers.py: Gate operator_user on user.visible in ValidatorWalletSerializer and WallOfShameSerializer. - backend/validators/views.py: Gate the grouped Wall of Shame _operator_user_payload on user.visible. - backend/tally/settings.py: Set SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE to not DEBUG so cookies are HTTPS-only in production. - backend/ethereum_auth/views.py: Log the exception and return a generic auth-failure message when DEBUG is off; keep full detail in DEBUG. - backend/validators/tests/test_grafana_service.py: Add regression test that a non-visible operator's identity is withheld while the validator still appears. * Update changelog --- CHANGELOG.md | 1 + backend/ethereum_auth/views.py | 5 ++- backend/tally/settings.py | 5 +++ backend/validators/serializers.py | 4 +- .../validators/tests/test_grafana_service.py | 40 +++++++++++++++++++ backend/validators/views.py | 2 +- 6 files changed, 53 insertions(+), 4 deletions(-) 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/ethereum_auth/views.py b/backend/ethereum_auth/views.py index a5b2a6f8..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 @@ -170,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 ) diff --git a/backend/tally/settings.py b/backend/tally/settings.py index a3559e62..1df75c07 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -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/validators/serializers.py b/backend/validators/serializers.py index 1157d20f..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, @@ -89,7 +89,7 @@ class Meta: 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 41220077..522c01ce 100644 --- a/backend/validators/tests/test_grafana_service.py +++ b/backend/validators/tests/test_grafana_service.py @@ -403,6 +403,46 @@ def test_grouped_validator_reasons_across_networks(self): {('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', diff --git a/backend/validators/views.py b/backend/validators/views.py index d5417ec1..763c8612 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -624,7 +624,7 @@ def _days_since(started_at, now): @staticmethod def _operator_user_payload(wallet): - if wallet.operator and wallet.operator.user: + if wallet.operator and wallet.operator.user and wallet.operator.user.visible: user = wallet.operator.user return { 'id': user.id, From 4b299b0bb9da4da04910e910f6e21f75989c51de Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 4 Jun 2026 19:20:07 +0200 Subject: [PATCH 6/6] Address CodeRabbit review feedback --- backend/api/metrics_views.py | 4 - backend/api/tests.py | 74 +++++++++++++------ ...aturedcontent_unique_content_type_title.py | 41 ++++++++++ backend/contributions/models.py | 6 ++ backend/poaps/tests/test_poaps.py | 10 ++- backend/poaps/views.py | 1 - .../0002_ensure_project_participants_table.py | 8 ++ frontend/src/components/Sidebar.svelte | 9 +++ frontend/src/routes/WallOfShame.svelte | 12 ++- package-lock.json | 2 +- 10 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 backend/contributions/migrations/0063_featuredcontent_unique_content_type_title.py diff --git a/backend/api/metrics_views.py b/backend/api/metrics_views.py index fa9ecacb..eedb955a 100644 --- a/backend/api/metrics_views.py +++ b/backend/api/metrics_views.py @@ -19,8 +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. """ - permission_classes = [permissions.AllowAny] - def get(self, request): from django.db.models.functions import TruncDate from datetime import date, timedelta @@ -92,8 +90,6 @@ class ContributionTypesStatsView(APIView): """ Get time series data showing how many contribution types have been assigned on each date. """ - permission_classes = [permissions.AllowAny] - def get(self, request): from django.db.models.functions import TruncDate from datetime import date, timedelta 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/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/poaps/tests/test_poaps.py b/backend/poaps/tests/test_poaps.py index cff577d2..bd31136b 100644 --- a/backend/poaps/tests/test_poaps.py +++ b/backend/poaps/tests/test_poaps.py @@ -308,7 +308,7 @@ 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_are_public(self): + def test_profile_poaps_require_authentication(self): PoapClaim.objects.create( drop=self.drop, user=self.user, @@ -317,6 +317,14 @@ def test_profile_poaps_are_public(self): 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) diff --git a/backend/poaps/views.py b/backend/poaps/views.py index 56114a2c..00f729da 100644 --- a/backend/poaps/views.py +++ b/backend/poaps/views.py @@ -326,7 +326,6 @@ class UserPoapMixin: detail=False, methods=['get'], url_path='by-address/(?P
[^/.]+)/poaps', - permission_classes=[permissions.AllowAny], ) def poaps(self, request, address=None): from django.shortcuts import get_object_or_404 diff --git a/backend/projects/migrations/0002_ensure_project_participants_table.py b/backend/projects/migrations/0002_ensure_project_participants_table.py index 28432200..09cb79e7 100644 --- a/backend/projects/migrations/0002_ensure_project_participants_table.py +++ b/backend/projects/migrations/0002_ensure_project_participants_table.py @@ -2,6 +2,14 @@ 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 diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index 19d78260..7ec851df 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -742,6 +742,15 @@ > Participants + { e.preventDefault(); navigate('/validators/wall-of-shame'); }} + class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' + }" + > + Wall of Shame + { e.preventDefault(); navigate('/validators/waitlist'); }} diff --git a/frontend/src/routes/WallOfShame.svelte b/frontend/src/routes/WallOfShame.svelte index d5f2b7a9..17305727 100644 --- a/frontend/src/routes/WallOfShame.svelte +++ b/frontend/src/routes/WallOfShame.svelte @@ -2,7 +2,7 @@ import { push } from 'svelte-spa-router'; import { validatorsAPI } from '../lib/api'; import Avatar from '../components/Avatar.svelte'; - import { showSuccess } from '../lib/toastStore'; + import { showSuccess, showError } from '../lib/toastStore'; /** * @typedef {Object} ShameReason @@ -278,9 +278,13 @@ {truncateAddress(validator.operator_address)} {/if}