From cfcc2c15d3296a981028a9592ee94bfb29f8f6f0 Mon Sep 17 00:00:00 2001 From: jedi Date: Sat, 1 Aug 2026 16:03:35 +0200 Subject: [PATCH] stash --- backend/authentication/api.py | 31 ++- backend/authentication/signature_auth.py | 16 +- backend/authentication/tests/test_auth.py | 46 ++++ backend/files/media_urls.py | 10 +- backend/toolshed/api/inventory.py | 17 +- backend/toolshed/serializers.py | 6 +- backend/toolshed/tests/test_offlinedata.py | 264 +++++++++++++++++++++ deploy/dev/instance_a/testdata.py | 2 +- deploy/dev/instance_b/nginx-b.dev.conf | 22 ++ deploy/docker-compose.override.yml | 8 +- frontend/src/components/UserNameTag.vue | 77 ++++++ frontend/src/federation.js | 28 ++- frontend/src/store.js | 45 +++- frontend/src/views/Friends.vue | 24 +- frontend/src/views/Search.vue | 125 +++++++--- 15 files changed, 644 insertions(+), 77 deletions(-) create mode 100644 backend/toolshed/tests/test_offlinedata.py create mode 100644 frontend/src/components/UserNameTag.vue diff --git a/backend/authentication/api.py b/backend/authentication/api.py index d31ac0d..f5c4542 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -10,7 +10,8 @@ from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.response import Response from authentication.models import ToolshedUser, AccountPreference -from authentication.signature_auth import SignatureAuthenticationLocal +from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \ + split_userhandle_or_throw from files.models import File from files.serializers import FileSerializer from hostadmin.models import Domain @@ -101,6 +102,9 @@ class UserViewSet(viewsets.ModelViewSet): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def getUserInfo(request): + """Get or update the authenticated local user's own account info. Only usable by the + account owner on their own home server - see getUserProfile for viewing another (friend) + user's public profile.""" user = request.user if request.method == 'PATCH': old_file = user.profile_picture @@ -137,6 +141,30 @@ def getUserInfo(request): }) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([SignatureAuthentication]) +def getUserProfile(request, handle): + """Get another local user's public profile by handle (username@domain), e.g. so a friend + can look up someone's avatar. The caller must be a friend of that user (or the user + itself, signing with their own known identity rather than their local credentials).""" + try: + username, domain = split_userhandle_or_throw(handle) + except ValueError: + return Response(status=400) + try: + target = ToolshedUser.objects.get(username=username, domain=domain) + except ToolshedUser.DoesNotExist: + return Response(status=404) + if target not in request.user.friends_or_self(): + return Response(status=403) + return Response({ + 'username': target.username, + 'domain': target.domain, + 'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None, + }) + + @api_view(['POST']) @permission_classes([]) @authentication_classes([]) @@ -212,6 +240,7 @@ router.register(r'users', UserViewSet) urlpatterns = [ path('', include(router.urls)), path('user/', getUserInfo), + path('user//', getUserProfile), path('register/', registerUser), path('token/', UserAuthToken.as_view()), path('preferences/', preference_definitions), diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index f6b280a..251ac04 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -106,11 +106,19 @@ def authenticate_request_against_local_users(request, raw_request_body): class SignatureAuthentication(authentication.BaseAuthentication): def authenticate(self, request): - return authenticate_request_against_known_identities( - request, request.body.decode('utf-8')), None + identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8')) + # Returning a bare None (rather than a (None, None) tuple) tells DRF this + # authenticator doesn't apply, so it moves on to the next authenticator in the + # authentication_classes list instead of treating the request as authenticated + # with an empty user. + if identity is None: + return None + return identity, None class SignatureAuthenticationLocal(authentication.BaseAuthentication): def authenticate(self, request): - return authenticate_request_against_local_users( - request, request.body.decode('utf-8')), None + user = authenticate_request_against_local_users(request, request.body.decode('utf-8')) + if user is None: + return None + return user, None diff --git a/backend/authentication/tests/test_auth.py b/backend/authentication/tests/test_auth.py index 1abdcaf..460095b 100644 --- a/backend/authentication/tests/test_auth.py +++ b/backend/authentication/tests/test_auth.py @@ -355,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase): self.assertEqual(reply.status_code, 403) +class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase): + """Tests for GET /auth/user// - viewing another (friend) user's public profile.""" + + def setUp(self): + super().setUp() + self.prepare_users() + self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity) + self.anonymous_client = Client(SERVER_NAME='testserver') + self.client = SignatureAuthClient() + + def test_view_friend_profile(self): + target = '/auth/user/' + str(self.f['local_user1']) + '/' + reply = self.client.get(target, self.f['ext_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.json()['username'], 'testuser1') + self.assertEqual(reply.json()['domain'], 'example.com') + self.assertIsNone(reply.json()['profile_picture']) + self.assertNotIn('email', reply.json()) + + def test_view_own_profile_via_handle(self): + target = '/auth/user/' + str(self.f['local_user1']) + '/' + reply = self.client.get(target, self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.json()['username'], 'testuser1') + + def test_view_profile_not_friend(self): + target = '/auth/user/' + str(self.f['local_user1']) + '/' + reply = self.client.get(target, self.f['ext_user2']) + self.assertEqual(reply.status_code, 403) + + def test_view_profile_unknown_user(self): + target = '/auth/user/nosuchuser@example.com/' + reply = self.client.get(target, self.f['ext_user1']) + self.assertEqual(reply.status_code, 404) + + def test_view_profile_bad_handle(self): + target = '/auth/user/notahandle/' + reply = self.client.get(target, self.f['ext_user1']) + self.assertEqual(reply.status_code, 400) + + def test_view_profile_unauthenticated(self): + target = '/auth/user/' + str(self.f['local_user1']) + '/' + reply = self.anonymous_client.get(target) + self.assertEqual(reply.status_code, 403) + + class FriendApiTestCase(UserTestMixin, ToolshedTestCase): def setUp(self): super().setUp() diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index d708ae0..92566ed 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -17,6 +17,12 @@ from files.models import File @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def media_urls(request, hash_path): + # Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware, + # configured in settings.py) adds them to every Django response automatically, so + # setting them manually on these responses would just be redundant. The one exception + # is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it + # follows the X-Accel-Redirect and serves the file itself, so the CORS header for that + # case has to be configured in nginx's `location /redirect_media/` block instead. try: file = File.objects.filter( Q(connected_items__owner__in=request.user.friends_or_self()) | @@ -29,14 +35,10 @@ def media_urls(request, hash_path): content_type=file.mime_type, headers={ 'X-Accel-Redirect': f'/redirect_media/{hash_path}', - 'Access-Control-Allow-Origin': '*', }) # TODO Expires and Cache-Control else: return HttpResponse(status=status.HTTP_200_OK, content_type=file.mime_type, - headers={ - 'Access-Control-Allow-Origin': '*', - }, content=open(file.file.path, 'rb').read()) diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 59433b3..5846e03 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -22,7 +22,8 @@ def inventory_items(identity): except ToolshedUser.DoesNotExist: pass for friend in identity.friends.all(): - if friend_user := friend.user.get(): + friend_user = friend.user.first() + if friend_user: for item in friend_user.inventory_items.all(): if item.availability_policy != 'private': yield item @@ -52,13 +53,25 @@ class InventoryItemViewSet(viewsets.ModelViewSet): instance.delete() +def matches_query(item, query): + query = query.lower() + if query in item.name.lower(): + return True + if item.description and query in item.description.lower(): + return True + if any(query in tag.name.lower() for tag in item.tags.all()): + return True + return False + + @api_view(['GET']) @authentication_classes([SignatureAuthentication]) @permission_classes([IsAuthenticated]) def search_inventory_items(request): query = request.query_params.get('query') if query: - return Response(InventoryItemSerializer(inventory_items(request.user), many=True).data) + matching_items = [item for item in inventory_items(request.user) if matches_query(item, query)] + return Response(InventoryItemSerializer(matching_items, many=True).data) return Response({'error': 'No query provided.'}, status=400) diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 02a5111..43c335b 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -94,7 +94,7 @@ class CategorySerializer(serializers.ModelSerializer): return obj.get_handle() def to_representation(self, instance): - return instance.get_handle() + return instance.name def to_internal_value(self, data): return resolve_category_handle(data.split("/")[-1]) @@ -129,7 +129,7 @@ class ItemPropertySerializer(serializers.ModelSerializer): return obj.property.get_handle() def to_representation(self, instance): - return {'value': instance.value, 'name': instance.property.name, 'handle': instance.property.get_handle()} + return {'value': instance.value, 'name': instance.property.name} def to_internal_value(self, data): prop = resolve_property_handle(data.get('name') or data.get('handle')) @@ -151,7 +151,7 @@ class InventoryItemSerializer(serializers.ModelSerializer): 'tags', 'tags_input', 'properties', 'files', 'storage_location'] def get_tags(self, obj): - return [tag.get_handle() for tag in obj.tags.all()] + return [tag.name for tag in obj.tags.all()] def to_internal_value(self, data): files = data.pop('files', []) diff --git a/backend/toolshed/tests/test_offlinedata.py b/backend/toolshed/tests/test_offlinedata.py new file mode 100644 index 0000000..499e597 --- /dev/null +++ b/backend/toolshed/tests/test_offlinedata.py @@ -0,0 +1,264 @@ +from django.core.files.base import ContentFile +from django.test import Client + +from authentication.models import AccountPreference, ToolshedUser +from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase +from files.models import File +from toolshed.models import InventoryItem, ItemProperty, StorageLocation +from toolshed.offlinedata import import_inventory, inventory_rows, rows_to_csv +from toolshed.tests import CategoryTestMixin, LocationTestMixin, PropertyTestMixin, TagTestMixin + +anonymous_client = Client() +client = SignatureAuthClient() + + +class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin): + """Shared fixture setup for the delete-data and delete-account test cases.""" + + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_categories() + self.prepare_locations() + + self.f['local_user1'].friends.add(self.f['local_user2'].public_identity) + + self.f['shared_file'] = File.objects.create( + file=ContentFile(b'shared', 'shared'), mime_type='text/plain', hash='shared') + self.f['orphan_file'] = File.objects.create( + file=ContentFile(b'orphan', 'orphan'), mime_type='text/plain', hash='orphan') + + self.f['item1'] = InventoryItem.objects.create( + owner=self.f['local_user1'], owned_quantity=1, name='item1', category=self.f['cat1']) + self.f['item1'].files.add(self.f['orphan_file']) + + self.f['item_other_user'] = InventoryItem.objects.create( + owner=self.f['local_user2'], owned_quantity=1, name='item2', category=self.f['cat1']) + self.f['item_other_user'].files.add(self.f['shared_file']) + + self.f['item1'].files.add(self.f['shared_file']) + + AccountPreference.objects.create(user=self.f['local_user1'], key='theme', value='dark') + + self.f['local_user1'].profile_picture = self.f['orphan_file'] + self.f['local_user1'].save() + + +class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase): + + def test_delete_data_anonymous(self): + response = anonymous_client.delete('/api/account_data/') + self.assertEqual(response.status_code, 403) + + def test_delete_data_removes_all_owned_data_but_keeps_account(self): + response = client.delete('/api/account_data/', self.f['local_user1']) + self.assertEqual(response.status_code, 200) + + summary = response.json() + self.assertEqual(summary['inventory_items'], 1) + self.assertEqual(summary['locations'], 4) + self.assertEqual(summary['settings'], 1) + self.assertEqual(summary['friends'], 1) + + # the account itself survives - this wipes data, it doesn't close the account + self.f['local_user1'].refresh_from_db() + self.assertTrue(ToolshedUser.objects.filter(username='testuser1').exists()) + self.assertIsNone(self.f['local_user1'].profile_picture) + + self.assertFalse(InventoryItem.global_objects.filter(owner_id=self.f['local_user1'].id).exists()) + self.assertFalse(StorageLocation.objects.filter(owner_id=self.f['local_user1'].id).exists()) + self.assertFalse(AccountPreference.objects.filter(user_id=self.f['local_user1'].id).exists()) + self.assertEqual(self.f['local_user1'].public_identity.friends.count(), 0) + + # orphaned file (only referenced by the deleted user/items) is gone + self.assertFalse(File.objects.filter(hash='orphan').exists()) + # file still referenced by the other user's item survives + self.assertTrue(File.objects.filter(hash='shared').exists()) + + # the other user's data and identity/friend relation to the deleted identity are untouched + self.f['local_user2'].refresh_from_db() + self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists()) + + +class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase): + + def test_delete_account_anonymous(self): + response = anonymous_client.delete('/api/account/') + self.assertEqual(response.status_code, 403) + + def test_delete_account_removes_data_and_closes_account(self): + user1_id = self.f['local_user1'].id + identity_id = self.f['local_user1'].public_identity_id + + response = client.delete('/api/account/', self.f['local_user1']) + self.assertEqual(response.status_code, 200) + + summary = response.json() + self.assertEqual(summary['inventory_items'], 1) + self.assertEqual(summary['locations'], 4) + self.assertEqual(summary['settings'], 1) + self.assertEqual(summary['friends'], 1) + self.assertTrue(summary['account']) + + # the account itself is gone + self.assertFalse(ToolshedUser.objects.filter(id=user1_id).exists()) + self.assertFalse(InventoryItem.global_objects.filter(owner_id=user1_id).exists()) + self.assertFalse(StorageLocation.objects.filter(owner_id=user1_id).exists()) + self.assertFalse(AccountPreference.objects.filter(user_id=user1_id).exists()) + + # the underlying identity is kept, so remote friends/history referencing it stay intact + from authentication.models import KnownIdentity + self.assertTrue(KnownIdentity.objects.filter(id=identity_id).exists()) + + # orphaned file (only referenced by the deleted user/items) is gone + self.assertFalse(File.objects.filter(hash='orphan').exists()) + # file still referenced by the other user's item survives + self.assertTrue(File.objects.filter(hash='shared').exists()) + + # the other user's data and identity/friend relation to the deleted identity are untouched + self.f['local_user2'].refresh_from_db() + self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists()) + self.assertEqual(self.f['local_user2'].public_identity.friends.count(), 0) + + +class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin, + ToolshedTestCase): + """Properties must round-trip through export/import even when their value contains a + comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of + the 'properties' CSV cell would misinterpret as a field/entry separator. + """ + + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_categories() + self.prepare_tags() + self.prepare_properties() + + def test_property_values_with_comma_and_equals_round_trip(self): + item = InventoryItem.objects.create(owner=self.f['local_user1'], name='widget') + ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm') + ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b') + + csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1'])))) + + imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={}) + self.assertEqual(errors, []) + self.assertEqual(imported, 1) + + new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='widget') + values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')} + self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'}) + + def test_legacy_comma_equals_format_is_still_importable(self): + handle1 = self.f['prop1'].get_handle() + handle2 = self.f['prop2'].get_handle() + csv_data = ( + 'name,properties\r\n' + f'legacy widget,"{handle1}=value1, {handle2}=value2"\r\n' + ).encode('utf-8') + + imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={}) + self.assertEqual(errors, []) + self.assertEqual(imported, 1) + + item = InventoryItem.objects.get(owner=self.f['local_user1'], name='legacy widget') + values = {ip.property.name: ip.value for ip in item.itemproperty_set.select_related('property')} + self.assertEqual(values, {'prop1': 'value1', 'prop2': 'value2'}) + + def test_item_without_properties_imports_cleanly(self): + item = InventoryItem.objects.create(owner=self.f['local_user1'], name='bare item') + + csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1'])))) + + imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={}) + self.assertEqual(errors, []) + self.assertEqual(imported, 1) + + new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='bare item') + self.assertEqual(list(new_item.itemproperty_set.all()), []) + + def test_category_and_tags_round_trip(self): + item = InventoryItem.objects.create( + owner=self.f['local_user1'], name='cat and tags item', category=self.f['cat1']) + item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={}) + + csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1'])))) + + imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={}) + self.assertEqual(errors, []) + self.assertEqual(imported, 1) + + new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='cat and tags item') + self.assertEqual(new_item.category, self.f['cat1']) + self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2']) + + def test_unknown_property_handle_skips_item_with_error(self): + csv_data = ( + 'name,properties\r\n' + 'ghost widget,test#property:doesnotexist=x\r\n' + ).encode('utf-8') + + imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={}) + self.assertEqual(imported, 0) + self.assertEqual(len(errors), 1) + self.assertIn('doesnotexist', errors[0]) + self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], name='ghost widget').exists()) + + def test_property_value_with_quote_character_round_trips(self): + item = InventoryItem.objects.create(owner=self.f['local_user1'], name='quoted widget') + ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='12" screen') + + csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1'])))) + + imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={}) + self.assertEqual(errors, []) + self.assertEqual(imported, 1) + + new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='quoted widget') + values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')} + self.assertEqual(values, {'prop1': '12" screen'}) + + +class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin, + ToolshedTestCase): + """End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by + clients), rather than calling the internal helper functions directly - this is what a real + export/import round trip between two accounts looks like. + """ + + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_categories() + self.prepare_tags() + self.prepare_properties() + + def test_export_then_import_preserves_category_tags_and_properties(self): + import base64 + + item = InventoryItem.objects.create( + owner=self.f['local_user1'], name='drill', description='cordless drill', + category=self.f['cat1'], availability_policy='friends', owned_quantity=2) + item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={}) + ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm') + ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b') + + export_reply = client.get('/api/export/', self.f['local_user1']) + self.assertEqual(export_reply.status_code, 200) + zip_bytes = export_reply.content + + import_reply = client.post('/api/import/', self.f['local_user2'], + {'zip': base64.b64encode(zip_bytes).decode('ascii')}) + self.assertEqual(import_reply.status_code, 200) + summary = import_reply.json() + self.assertEqual(summary['inventory_items'], 1) + self.assertEqual(summary['errors'], []) + + new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill') + self.assertEqual(new_item.category, self.f['cat1']) + self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2']) + + values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')} + self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'}) + diff --git a/deploy/dev/instance_a/testdata.py b/deploy/dev/instance_a/testdata.py index 4a5cfbd..0e8f592 100644 --- a/deploy/dev/instance_a/testdata.py +++ b/deploy/dev/instance_a/testdata.py @@ -57,7 +57,7 @@ def create_test_data(): if KnownIdentity.objects.filter(username='test_b').exists(): identity = KnownIdentity.objects.get(username='test_b') identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost', - public_key='4b0dffe21764c591762615ef84cfea7fd4055fab3e9f58ae077fd8c79c34af91') + public_key='14c44f03c3a0406934cf3a27b0eeedae8a08a3f436690c7103eca13435172a8c') # pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40' identity.save() print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain, diff --git a/deploy/dev/instance_b/nginx-b.dev.conf b/deploy/dev/instance_b/nginx-b.dev.conf index bb6596c..e181011 100644 --- a/deploy/dev/instance_b/nginx-b.dev.conf +++ b/deploy/dev/instance_b/nginx-b.dev.conf @@ -35,6 +35,28 @@ http { proxy_pass http://backend; } + location /media { + proxy_set_header Host $host:$server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; + proxy_pass http://backend; + } + + location /redirect_media/ { + internal; + alias /var/www/userfiles/; + # This location serves the file directly, bypassing Django (and therefore + # django-cors-headers) entirely - it's the target of the X-Accel-Redirect + # response from files/media_urls.py, used when SERVE_X_ACCEL_REDIRECT=True. + # CORS headers must be added here explicitly since nothing else will. + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Accept, Content-Type, Origin, User-Agent' always; + } + location /docs { proxy_pass http://backend/docs; } diff --git a/deploy/docker-compose.override.yml b/deploy/docker-compose.override.yml index 8060c1e..cb3cdd3 100644 --- a/deploy/docker-compose.override.yml +++ b/deploy/docker-compose.override.yml @@ -10,9 +10,10 @@ services: - ../deploy/dev/instance_a/a.env:/code/.env - ../deploy/dev/instance_a/testdata.py:/code/testdata.py - ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3 + - ../deploy/dev/instance_a/userfiles:/code/userfiles expose: - 8000 - command: bash -c "python configure.py; python configure.py testdata; python manage.py migrate; python manage.py runserver 0.0.0.0:8000 --insecure" + command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" backend-b: build: @@ -23,9 +24,10 @@ services: - ../deploy/dev/instance_b/b.env:/code/.env - ../deploy/dev/instance_b/testdata.py:/code/testdata.py - ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3 + - ../deploy/dev/instance_b/userfiles:/code/userfiles expose: - 8000 - command: bash -c "python configure.py; python configure.py testdata; python manage.py migrate; python manage.py runserver 0.0.0.0:8000 --insecure" + command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" frontend: build: @@ -57,6 +59,7 @@ services: - ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro - ./dev/instance_a/dns.json:/var/www/dns.json:ro - ./dev/instance_a/domains.json:/var/www/domains.json:ro + - ./dev/instance_a/userfiles:/var/www/userfiles:ro ports: - "127.0.0.1:8080:8080" - "127.0.0.3:5353:5353" @@ -67,6 +70,7 @@ services: dockerfile: dev/Dockerfile.proxy volumes: - ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro + - ./dev/instance_b/userfiles:/var/www/userfiles:ro ports: - "127.0.0.2:8080:8080" diff --git a/frontend/src/components/UserNameTag.vue b/frontend/src/components/UserNameTag.vue new file mode 100644 index 0000000..abd3b49 --- /dev/null +++ b/frontend/src/components/UserNameTag.vue @@ -0,0 +1,77 @@ + + + + + + + + diff --git a/frontend/src/federation.js b/frontend/src/federation.js index 5fa5661..333e0e6 100644 --- a/frontend/src/federation.js +++ b/frontend/src/federation.js @@ -282,6 +282,23 @@ class ServerSet { function ServerSetUnion(serverSets) { return new Proxy(serverSets, { get: function (target, prop, receiver) { + // Note: 'add' must be checked before the generic funcs-forwarding branch below, + // because ServerSet.prototype also defines its own `add(server)` method (for + // adding a raw server address string to a single ServerSet). Without this check + // first, `funcs.includes('add')` would always be true and the union-specific + // "add a ServerSet to this union" logic below would never be reached. + if (prop === 'add') { + return function (serverset) { + if (!serverset || !(serverset instanceof ServerSet)) { + throw new Error('no serverset') + } + if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) { + console.warn('serverset already in union', serverset) + return + } + target.push(serverset) + } + } const funcs = Object.getOwnPropertyNames(ServerSet.prototype) if (funcs.includes(prop)) { return async function (...args) { @@ -295,17 +312,6 @@ function ServerSetUnion(serverSets) { throw new Error('all servers failed') } } - } else if (prop === 'add') { - return function (serverset) { - if (!serverset || !(serverset instanceof ServerSet)) { - throw new Error('no serverset') - } - if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) { - console.warn('serverset already in union', serverset) - return - } - target.push(serverset) - } } else { return target[prop] } diff --git a/frontend/src/store.js b/frontend/src/store.js index 907ffac..6229a08 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -71,6 +71,7 @@ export default createStore({ keypair: null, remember: false, friends: [], + friendProfiles: {}, item_map: {}, home_servers: null, all_friends_servers: null, @@ -97,6 +98,12 @@ export default createStore({ setFriends(state, friends) { state.friends = friends; }, + setFriendProfile(state, {username, profile}) { + state.friendProfiles = {...state.friendProfiles, [username]: profile}; + }, + setFriendProfiles(state, profiles) { + state.friendProfiles = profiles; + }, setHomeServers(state, home_servers) { state.home_servers = home_servers; }, @@ -326,11 +333,21 @@ export default createStore({ const home = await dispatch('getHomeServers') servers.add(home) for (const friend of friends) { - const s = await dispatch('lookupServer', {username: friend.username}) - servers.add(new ServerSet(s, state.unreachable_neighbors)) + try { + const s = await dispatch('lookupServer', {username: friend.username}) + servers.add(new ServerSet(s, state.unreachable_neighbors)) + } catch (e) { + // Don't let a single unresolvable/unreachable friend abort the whole + // search/federation lookup - just skip them and continue. + console.error('could not resolve server for friend', friend.username, e) + } } return servers - })() + })().catch(e => { + // Don't cache a permanently-rejected promise - allow a retry next time. + state.all_friends_servers = null + throw e + }) commit('setAllFriendsServers', promise) return promise }, @@ -376,6 +393,20 @@ export default createStore({ commit('setFriends', data) return data }, + async fetchFriendProfile({commit, dispatch, getters}, {username}) { + try { + const servers = await dispatch('getFriendServers', {username}); + // username here is already a full handle (username@domain), see FriendSerializer + const profile = await servers.get(getters.signAuth, '/auth/user/' + username + '/'); + if (profile) { + commit('setFriendProfile', {username, profile}); + } + return profile; + } catch (error) { + console.error(`Failed to fetch profile for ${username}:`, error); + return null; + } + }, async fetchFriendRequests({state, dispatch, getters}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/friendrequests/') @@ -612,6 +643,14 @@ export default createStore({ return state.user !== null && (state.token !== null || state.keypair !== null) }, + allUserProfiles(state) { + // Combine user's own profile with all friend profiles + const combined = {...state.friendProfiles}; + if (state.user && state.user_profile) { + combined[state.user] = state.user_profile; + } + return combined; + }, signAuth(state) { return createSignAuth(state.user, state.keypair.signSk) }, diff --git a/frontend/src/views/Friends.vue b/frontend/src/views/Friends.vue index 7d49453..9d9bc2b 100644 --- a/frontend/src/views/Friends.vue +++ b/frontend/src/views/Friends.vue @@ -36,9 +36,11 @@ - - {{ friend.username }} - {{ friend.server.join(', ')}} + + + + + {{ friend.server.join(', ')}} - - - - + + + + + + + + + + + + + + + + + +
NameOwnerAmountActions
+ {{ item.name }} + + + {{ item.owned_quantity }} + +
+
+
+
+
+ +
+
+ + {{ item.name }} +
+
+ +
+ {{ item.owned_quantity }} +
+
+
+
+
@@ -57,11 +82,15 @@ import {mapActions} from 'vuex'; import * as BIcons from "bootstrap-icons-vue"; import BaseLayout from "@/components/BaseLayout.vue"; import SearchBox from "@/components/SearchBox.vue"; +import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; +import UserNameTag from "@/components/UserNameTag.vue"; export default { name: 'Search', components: { SearchBox, + AuthenticatedImage, + UserNameTag, ...BIcons, BaseLayout }, @@ -74,19 +103,37 @@ export default { data() { return { search_results: [], + layout: 'table' } }, methods: { ...mapActions(['fetchSearchResults']), + only_images(files) { + if (!files) return []; + return files.filter(file => file.mime_type.startsWith("image/")); + }, + loadResults() { + this.fetchSearchResults({query: this.query}).then((results) => { + this.search_results = results; + }); + } }, - async mounted() { - this.fetchSearchResults({query: this.query}).then((results) => { - this.search_results = results; - }); + watch: { + query: { + immediate: false, + handler() { + this.loadResults(); + } + } + }, + mounted() { + this.loadResults(); } } \ No newline at end of file