This commit is contained in:
j3d1 2026-08-01 16:03:35 +02:00
parent 2f8683add1
commit cfcc2c15d3
15 changed files with 644 additions and 77 deletions

View file

@ -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/<str:handle>/', getUserProfile),
path('register/', registerUser),
path('token/', UserAuthToken.as_view()),
path('preferences/', preference_definitions),

View file

@ -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

View file

@ -355,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 403)
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
"""Tests for GET /auth/user/<handle>/ - 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()

View file

@ -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())

View file

@ -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)

View file

@ -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', [])

View file

@ -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'})

View file

@ -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,

View file

@ -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;
}

View file

@ -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"

View file

@ -0,0 +1,77 @@
<template>
<div class="d-flex align-items-center">
<authenticated-image v-if="profilePictureUrl"
:src="profilePictureUrl"
:owner="displayUsername"
:alt="displayUsername"
:title="displayUsername"
img-class="rounded-circle mr-2"
:width="width"
:height="height"
fit="cover"/>
<span>{{ displayUsername }}</span>
</div>
</template>
<script>
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import {mapGetters, mapActions} from 'vuex';
export default {
name: 'UserNameTag',
components: {
AuthenticatedImage
},
props: {
user: {
type: Object,
required: true
},
width: {
type: Number,
default: 32
},
height: {
type: Number,
default: 32
}
},
computed: {
...mapGetters(['allUserProfiles']),
displayUsername() {
return this.user.username || this.user.owner || '';
},
profilePictureUrl() {
// Check for explicitly set profile picture from allUserProfiles getter
const storedProfile = this.allUserProfiles[this.displayUsername];
if (storedProfile?.profile_picture?.name) {
return storedProfile.profile_picture.name;
}
return null;
}
},
methods: {
...mapActions(['fetchFriendProfile']),
fetchProfileIfNeeded() {
// Fetch profile if not already in store
if (!this.allUserProfiles[this.displayUsername] && this.displayUsername) {
this.fetchFriendProfile({username: this.displayUsername});
}
}
},
mounted() {
this.fetchProfileIfNeeded();
},
watch: {
displayUsername() {
this.fetchProfileIfNeeded();
}
}
}
</script>
<style scoped>
</style>

View file

@ -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]
}

View file

@ -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)
},

View file

@ -36,9 +36,11 @@
<button class="btn btn-primary" @click="tryRequestFriend">Send request</button>
</td>
</tr>
<tr v-for="friend in friendslist" :key="friend.name">
<td>{{ friend.username }}</td>
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
<tr v-for="friend in friendslist" :key="friend.name">
<td>
<user-name-tag :user="friend"/>
</td>
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
<td class="table-action">
<!--a href="#" class="align-middle">
<b-icon-pencil-square></b-icon-pencil-square>
@ -76,7 +78,7 @@
<tr v-for="request in requests" :key="request.befriender">
<td>{{ request.befriender }}</td>
<td class="d-none d-md-table-cell">
{{ request.befriender_public_key.slice(0, 32) }}...
{{ request.befriender_public_key?.slice(0, 32) ?? 'N/A' }}...
</td>
<td class="table-action">
<button class="btn btn-sm btn-success" @click="tryAcceptFriend(request)">
@ -100,14 +102,18 @@
</template>
<script>
import {mapActions, mapGetters} from "vuex";
import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import UserNameTag from "@/components/UserNameTag.vue";
export default {
name: 'Inventory',
components: {
BaseLayout,
AuthenticatedImage,
UserNameTag,
...BIcons
},
data() {
@ -119,6 +125,7 @@ export default {
}
},
computed: {
...mapState(['user']),
username() {
return this.$route.params.username
},
@ -128,12 +135,15 @@ export default {
},
methods: {
...mapActions(['fetchFriends', "lookupServer", "requestFriend", "acceptFriend", "fetchFriendRequests",
"declineFriend", "dropFriend"]),
"declineFriend", "dropFriend", "getFriendServers", "fetchFriendProfile"]),
fetchContent() {
this.fetchFriends().then((friends) => {
friends.map((friend) => {
// Handle both object and array formats
const friendsList = Array.isArray(friends) ? friends : Object.values(friends);
friendsList.map((friend) => {
this.lookupServer(friend).then((server) => {
this.friends[friend.username] = {...friend, server: server}
this.fetchFriendProfile({username: friend.username});
})
})
})

View file

@ -4,45 +4,70 @@
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Search Inventories</h1>
<div class="row">
<div class="col-md-3">
<div class="card">
<SearchBox @change=""/>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Results for "{{ query }}"</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Owner</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in search_results" :key="item.id">
<td>
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
</td>
<td>{{ item.owner }}</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<!--<router-link :to="`/inventory/${item.id}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`/inventory/${item.id}/delete`"
@click.prevent="deleteInventoryItem(item)">
<b-icon-trash></b-icon-trash>
</a>-->
</td>
</tr>
</tbody>
</table>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Owner</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in search_results" :key="item.id">
<td>
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
</td>
<td>
<user-name-tag :user="item"/>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<!--<router-link :to="`/inventory/${item.id}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`/inventory/${item.id}/delete`"
@click.prevent="deleteInventoryItem(item)">
<b-icon-trash></b-icon-trash>
</a>-->
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in search_results"
:key="item.id">
<div class="card">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="`/inventory/${item.id}`">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
<user-name-tag :user="item" :width="24" :height="24"/>
<br>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -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();
}
}
</script>
<style scoped>
.img-preview {
object-fit: cover;
}
</style>