stash
This commit is contained in:
parent
6ee5ae38b1
commit
9685b4020e
11 changed files with 331 additions and 268 deletions
|
|
@ -2,7 +2,7 @@ from nacl.exceptions import BadSignatureError
|
||||||
from nacl.signing import VerifyKey
|
from nacl.signing import VerifyKey
|
||||||
from rest_framework import authentication
|
from rest_framework import authentication
|
||||||
|
|
||||||
from authentication.models import KnownIdentity, ToolshedUser
|
from authentication.models import Group, KnownIdentity, ToolshedUser
|
||||||
|
|
||||||
|
|
||||||
def split_userhandle_or_throw(userhandle):
|
def split_userhandle_or_throw(userhandle):
|
||||||
|
|
@ -22,6 +22,21 @@ def split_grouphandle_or_throw(grouphandle):
|
||||||
return split_userhandle_or_throw(grouphandle[1:])
|
return split_userhandle_or_throw(grouphandle[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_owner_handle(handle):
|
||||||
|
"""Resolves a "user@domain" or "+name@domain" handle to (owner_user, owner_group), exactly one set. Raises ValueError if the handle doesn't parse at all (no '@'); returns (None, None) if it parses but names nothing that exists."""
|
||||||
|
is_group = handle.startswith('+')
|
||||||
|
name, domain = split_userhandle_or_throw(handle[1:] if is_group else handle)
|
||||||
|
if is_group:
|
||||||
|
try:
|
||||||
|
return None, Group.objects.get(name=name, domain=domain)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
return ToolshedUser.objects.get(username=name, domain=domain), None
|
||||||
|
except ToolshedUser.DoesNotExist:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def verify_request(request, raw_request_body):
|
def verify_request(request, raw_request_body):
|
||||||
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.db.models import Q
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.viewsets import ViewSetMixin
|
from rest_framework.viewsets import ViewSetMixin
|
||||||
|
|
||||||
from authentication.models import KnownIdentity
|
from authentication.models import KnownIdentity, ToolshedUser, Group
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from toolshed.models import InventoryItem, StorageLocation
|
||||||
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
|
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,6 +25,45 @@ class IdMap(APIView, ViewSetMixin):
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def resolve_short_id(request, kind, owner_id, local_id):
|
||||||
|
"""Resolves a short id's (kind, owner_id, local_id) against this backend's own numbering. See docs/implementation.md#domain-qualified-short-id-resolution."""
|
||||||
|
if kind in ('item', 'storage_location'):
|
||||||
|
try:
|
||||||
|
owner = KnownIdentity.objects.get(pk=owner_id).user.get()
|
||||||
|
except (KnownIdentity.DoesNotExist, ToolshedUser.DoesNotExist):
|
||||||
|
return Response(status=404)
|
||||||
|
if owner not in request.user.friends_or_self():
|
||||||
|
return Response(status=403)
|
||||||
|
model = InventoryItem if kind == 'item' else StorageLocation
|
||||||
|
try:
|
||||||
|
obj = model.objects.get(owner=owner, id=local_id)
|
||||||
|
except model.DoesNotExist:
|
||||||
|
return Response(status=404)
|
||||||
|
is_owner = request.user.user.filter(pk=owner.pk).exists()
|
||||||
|
if getattr(obj, 'availability_policy', 'share') == 'private' and not is_owner:
|
||||||
|
return Response(status=403)
|
||||||
|
return Response({'handle': f'{owner.username}@{owner.domain}', 'id': obj.id})
|
||||||
|
if kind in ('group_item', 'group_storage_location'):
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(pk=owner_id)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
return Response(status=404)
|
||||||
|
if not group.is_member(request.user):
|
||||||
|
return Response(status=403)
|
||||||
|
model = InventoryItem if kind == 'group_item' else StorageLocation
|
||||||
|
try:
|
||||||
|
obj = model.objects.get(owner_group=group, id=local_id)
|
||||||
|
except model.DoesNotExist:
|
||||||
|
return Response(status=404)
|
||||||
|
return Response({'handle': str(group), 'id': obj.id})
|
||||||
|
return Response(status=400)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('idmap/', IdMap.as_view(), name='idmap'),
|
path('idmap/', IdMap.as_view(), name='idmap'),
|
||||||
|
path('resolve_short_id/<str:kind>/<int:owner_id>/<int:local_id>/', resolve_short_id,
|
||||||
|
name='resolve_short_id'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,16 @@ from django.db import transaction
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework import routers, viewsets, status
|
from rest_framework import routers, viewsets, status
|
||||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
||||||
from rest_framework.exceptions import NotFound, PermissionDenied
|
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity, Group
|
from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
|
from authentication.signature_auth import SignatureAuthentication, resolve_owner_handle
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_group_by_handle(handle):
|
|
||||||
"""handle is "name@domain" (no leading '#') - the same format/parser group.py's GroupDetail
|
|
||||||
uses, so a group reference parses identically everywhere it appears (URL path, ?group= query
|
|
||||||
param, or an owner_group payload field), rather than some spots taking a handle and others a
|
|
||||||
bare pk."""
|
|
||||||
try:
|
|
||||||
name, domain = split_userhandle_or_throw(handle)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return Group.objects.get(name=name, domain=domain)
|
|
||||||
except Group.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def inventory_items(identity):
|
def inventory_items(identity):
|
||||||
try:
|
try:
|
||||||
|
|
@ -50,47 +33,48 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = InventoryItemSerializer
|
serializer_class = InventoryItemSerializer
|
||||||
authentication_classes = [SignatureAuthentication]
|
authentication_classes = [SignatureAuthentication]
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
# Detail routes address an item by its owner-scoped id, not the internal row id. See
|
# Detail routes address an item by its owner-scoped id, not the internal row id. See docs/implementation.md#owner-handle-scoped-routes.
|
||||||
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
|
||||||
lookup_field = 'id'
|
lookup_field = 'id'
|
||||||
lookup_url_kwarg = 'pk'
|
lookup_url_kwarg = 'pk'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# A pure group-member KnownIdentity may have no local ToolshedUser account; only
|
# Every route is scoped by the owner handle URL param (own/friend/group); no handle-less fallback exists. See docs/implementation.md#owner-handle-scoped-routes.
|
||||||
# personal items require .user.exists(). See
|
|
||||||
# docs/implementation.md#group-member-identities-without-local-accounts.
|
|
||||||
if type(self.request.user) != KnownIdentity:
|
if type(self.request.user) != KnownIdentity:
|
||||||
return InventoryItem.objects.none()
|
return InventoryItem.objects.none()
|
||||||
identity = self.request.user
|
identity = self.request.user
|
||||||
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
|
try:
|
||||||
if self.action != 'list':
|
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||||
# retrieve/update/destroy: any item the caller may act on, own or group. See
|
except ValueError:
|
||||||
# docs/implementation.md#inventory-queryset-scope-by-action.
|
raise ValidationError('invalid owner handle')
|
||||||
if identity.user.exists():
|
if owner_group:
|
||||||
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
|
if not owner_group.is_member(identity):
|
||||||
return group_items
|
|
||||||
group_handle = self.request.query_params.get('group')
|
|
||||||
if group_handle:
|
|
||||||
group = resolve_group_by_handle(group_handle)
|
|
||||||
if not group or not group.is_member(identity):
|
|
||||||
return InventoryItem.objects.none()
|
return InventoryItem.objects.none()
|
||||||
return InventoryItem.objects.filter(owner_group=group)
|
return InventoryItem.objects.filter(owner_group=owner_group)
|
||||||
if identity.user.exists():
|
if owner_user:
|
||||||
return InventoryItem.objects.filter(owner=identity.user.get())
|
if owner_user not in identity.friends_or_self():
|
||||||
|
return InventoryItem.objects.none()
|
||||||
|
queryset = InventoryItem.objects.filter(owner=owner_user)
|
||||||
|
if not identity.user.filter(pk=owner_user.pk).exists():
|
||||||
|
queryset = queryset.exclude(availability_policy='private')
|
||||||
|
return queryset
|
||||||
return InventoryItem.objects.none()
|
return InventoryItem.objects.none()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
group_handle = self.request.data.get('owner_group')
|
try:
|
||||||
|
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError('invalid owner handle')
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if group_handle:
|
if owner_group:
|
||||||
group = resolve_group_by_handle(group_handle)
|
if not owner_group.is_member(self.request.user):
|
||||||
if not group:
|
|
||||||
raise NotFound('No such group')
|
|
||||||
if not group.is_member(self.request.user):
|
|
||||||
raise PermissionDenied('Not a member of this group')
|
raise PermissionDenied('Not a member of this group')
|
||||||
serializer.save(owner=None, owner_group=group).clean()
|
serializer.save(owner=None, owner_group=owner_group).clean()
|
||||||
|
elif owner_user:
|
||||||
|
if not self.request.user.user.filter(pk=owner_user.pk).exists():
|
||||||
|
raise PermissionDenied('Cannot create items for another user')
|
||||||
|
serializer.save(owner=owner_user).clean()
|
||||||
else:
|
else:
|
||||||
serializer.save(owner=self.request.user.user.get()).clean()
|
raise NotFound('No such owner')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_authorized(request, instance):
|
def _is_authorized(request, instance):
|
||||||
|
|
@ -99,13 +83,16 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
||||||
return instance.owner_group.is_member(request.user)
|
return instance.owner_group.is_member(request.user)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
# get_queryset() may return a friend's read-only item; reject explicitly rather than silently no-op. See docs/implementation.md#perform-update-rejects-non-owned-items-explicitly.
|
||||||
|
if not self._is_authorized(self.request, serializer.instance):
|
||||||
|
raise PermissionDenied('Not authorized to modify this item')
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if self._is_authorized(self.request, serializer.instance):
|
serializer.save().clean()
|
||||||
serializer.save().clean()
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
if self._is_authorized(self.request, instance):
|
if not self._is_authorized(self.request, instance):
|
||||||
instance.delete()
|
raise PermissionDenied('Not authorized to delete this item')
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
def matches_query(item, query):
|
def matches_query(item, query):
|
||||||
|
|
@ -130,75 +117,49 @@ def search_inventory_items(request):
|
||||||
return Response({'error': 'No query provided.'}, status=400)
|
return Response({'error': 'No query provided.'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
|
||||||
@authentication_classes([SignatureAuthentication])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def get_shared_item(request, handle, id):
|
|
||||||
"""Fetch a single item by its owner's handle and local id, for /i/<handle>/<id> or
|
|
||||||
/inventory/shared/<handle>/<id>. See docs/implementation.md#get-shared-item-looks-up-by-owner."""
|
|
||||||
try:
|
|
||||||
username, domain = split_userhandle_or_throw(handle)
|
|
||||||
except ValueError:
|
|
||||||
return Response(status=400)
|
|
||||||
try:
|
|
||||||
owner = ToolshedUser.objects.get(username=username, domain=domain)
|
|
||||||
except ToolshedUser.DoesNotExist:
|
|
||||||
return Response(status=404)
|
|
||||||
if owner not in request.user.friends_or_self():
|
|
||||||
return Response(status=403)
|
|
||||||
try:
|
|
||||||
item = owner.inventory_items.get(id=id)
|
|
||||||
except InventoryItem.DoesNotExist:
|
|
||||||
return Response(status=404)
|
|
||||||
is_owner = request.user.user.filter(pk=owner.pk).exists()
|
|
||||||
if item.availability_policy == 'private' and not is_owner:
|
|
||||||
return Response(status=403)
|
|
||||||
return Response(InventoryItemSerializer(item).data)
|
|
||||||
|
|
||||||
|
|
||||||
class StorageLocationViewSet(viewsets.ModelViewSet):
|
class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = StorageLocationSerializer
|
serializer_class = StorageLocationSerializer
|
||||||
authentication_classes = [SignatureAuthentication]
|
authentication_classes = [SignatureAuthentication]
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
# Detail routes address a location by its owner-scoped id, not the internal row id. See
|
# Detail routes address a location by its owner-scoped id, not the internal row id. See docs/implementation.md#owner-handle-scoped-routes.
|
||||||
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
|
||||||
lookup_field = 'id'
|
lookup_field = 'id'
|
||||||
lookup_url_kwarg = 'pk'
|
lookup_url_kwarg = 'pk'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Mirrors InventoryItemViewSet.get_queryset() - see its own comments for why the
|
# See docs/implementation.md#owner-handle-scoped-routes; unlike items, StorageLocation has no availability_policy, so a friend sees all of a user's locations.
|
||||||
# list/detail scopes differ and why group membership alone (no linked ToolshedUser
|
|
||||||
# required) is enough for the group branch.
|
|
||||||
if type(self.request.user) != KnownIdentity:
|
if type(self.request.user) != KnownIdentity:
|
||||||
return StorageLocation.objects.none()
|
return StorageLocation.objects.none()
|
||||||
identity = self.request.user
|
identity = self.request.user
|
||||||
group_locations = StorageLocation.objects.filter(owner_group__in=identity.member_of_groups.all())
|
try:
|
||||||
if self.action != 'list':
|
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||||
if identity.user.exists():
|
except ValueError:
|
||||||
return StorageLocation.objects.filter(owner=identity.user.get()) | group_locations
|
raise ValidationError('invalid owner handle')
|
||||||
return group_locations
|
if owner_group:
|
||||||
group_handle = self.request.query_params.get('group')
|
if not owner_group.is_member(identity):
|
||||||
if group_handle:
|
|
||||||
group = resolve_group_by_handle(group_handle)
|
|
||||||
if not group or not group.is_member(identity):
|
|
||||||
return StorageLocation.objects.none()
|
return StorageLocation.objects.none()
|
||||||
return StorageLocation.objects.filter(owner_group=group)
|
return StorageLocation.objects.filter(owner_group=owner_group)
|
||||||
if identity.user.exists():
|
if owner_user:
|
||||||
return StorageLocation.objects.filter(owner=identity.user.get())
|
if owner_user not in identity.friends_or_self():
|
||||||
|
return StorageLocation.objects.none()
|
||||||
|
return StorageLocation.objects.filter(owner=owner_user)
|
||||||
return StorageLocation.objects.none()
|
return StorageLocation.objects.none()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
group_handle = self.request.data.get('owner_group')
|
try:
|
||||||
|
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError('invalid owner handle')
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if group_handle:
|
if owner_group:
|
||||||
group = resolve_group_by_handle(group_handle)
|
if not owner_group.is_member(self.request.user):
|
||||||
if not group:
|
|
||||||
raise NotFound('No such group')
|
|
||||||
if not group.is_member(self.request.user):
|
|
||||||
raise PermissionDenied('Not a member of this group')
|
raise PermissionDenied('Not a member of this group')
|
||||||
serializer.save(owner=None, owner_group=group).clean()
|
serializer.save(owner=None, owner_group=owner_group).clean()
|
||||||
|
elif owner_user:
|
||||||
|
if not self.request.user.user.filter(pk=owner_user.pk).exists():
|
||||||
|
raise PermissionDenied('Cannot create locations for another user')
|
||||||
|
serializer.save(owner=owner_user).clean()
|
||||||
else:
|
else:
|
||||||
serializer.save(owner=self.request.user.user.get()).clean()
|
raise NotFound('No such owner')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_authorized(request, instance):
|
def _is_authorized(request, instance):
|
||||||
|
|
@ -207,13 +168,16 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||||
return instance.owner_group.is_member(request.user)
|
return instance.owner_group.is_member(request.user)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
# See docs/implementation.md#perform-update-rejects-non-owned-items-explicitly.
|
||||||
|
if not self._is_authorized(self.request, serializer.instance):
|
||||||
|
raise PermissionDenied('Not authorized to modify this location')
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if self._is_authorized(self.request, serializer.instance):
|
serializer.save().clean()
|
||||||
serializer.save().clean()
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
if self._is_authorized(self.request, instance):
|
if not self._is_authorized(self.request, instance):
|
||||||
instance.delete()
|
raise PermissionDenied('Not authorized to delete this location')
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
|
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
|
||||||
|
|
@ -246,11 +210,11 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
|
||||||
file.delete()
|
file.delete()
|
||||||
|
|
||||||
|
|
||||||
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
|
router = routers.SimpleRouter()
|
||||||
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
|
router.register(r'inventory_items/(?P<handle>[^/]+)', InventoryItemViewSet, basename='inventory_items')
|
||||||
|
router.register(r'storage_locations/(?P<handle>[^/]+)', StorageLocationViewSet, basename='storage_locations')
|
||||||
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
|
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
|
||||||
|
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = router.urls + [
|
||||||
path('search/', search_inventory_items, name='search_inventory_items'),
|
path('search/', search_inventory_items, name='search_inventory_items'),
|
||||||
path('inventory_items/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
|
||||||
def test_version_anonymous(self):
|
def test_version_anonymous(self):
|
||||||
response = anonymous_client.get('/api/version/')
|
response = anonymous_client.get('/api/version/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION})
|
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION, 'commit': settings.GIT_COMMIT})
|
||||||
|
|
||||||
def test_version_authenticated(self):
|
def test_version_authenticated(self):
|
||||||
response = client.get('/api/version/', self.f['local_user1'])
|
response = client.get('/api/version/', self.f['local_user1'])
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION})
|
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION, 'commit': settings.GIT_COMMIT})
|
||||||
|
|
||||||
def test_domains_anonymous(self):
|
def test_domains_anonymous(self):
|
||||||
response = anonymous_client.get('/api/domains/')
|
response = anonymous_client.get('/api/domains/')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
|
|
||||||
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
|
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
|
||||||
|
from toolshed.models import InventoryItem, StorageLocation
|
||||||
|
from toolshed.tests import InventoryTestMixin, LocationTestMixin
|
||||||
|
|
||||||
client = SignatureAuthClient()
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
@ -46,3 +48,95 @@ class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
def test_idmap_unauthenticated(self):
|
def test_idmap_unauthenticated(self):
|
||||||
reply = Client().get('/api/idmap/')
|
reply = Client().get('/api/idmap/')
|
||||||
self.assertEqual(reply.status_code, 403)
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveShortIdApiTestCase(UserTestMixin, InventoryTestMixin, GroupTestMixin, LocationTestMixin,
|
||||||
|
ToolshedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_categories()
|
||||||
|
self.prepare_tags()
|
||||||
|
self.prepare_properties()
|
||||||
|
self.prepare_inventory()
|
||||||
|
self.prepare_groups()
|
||||||
|
self.prepare_locations()
|
||||||
|
self.owner_id = self.f['local_user1'].public_identity.pk
|
||||||
|
|
||||||
|
def test_resolve_item_as_owner(self):
|
||||||
|
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['item1'].id})
|
||||||
|
|
||||||
|
def test_resolve_item_as_friend(self):
|
||||||
|
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
||||||
|
self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
|
||||||
|
def test_resolve_item_not_friend(self):
|
||||||
|
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
||||||
|
self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
def test_resolve_item_private_not_owner(self):
|
||||||
|
private_item = InventoryItem.create_for_owner(
|
||||||
|
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
|
||||||
|
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
|
||||||
|
self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
|
||||||
|
def test_resolve_item_unknown_owner(self):
|
||||||
|
reply = client.get('/api/resolve_short_id/item/999999/1/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_resolve_item_unknown_local_id(self):
|
||||||
|
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/999999/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_resolve_unsupported_kind(self):
|
||||||
|
# workflow/category/file/group aren't wired up yet - see
|
||||||
|
# docs/handles-and-shortids.md#domain-qualified-short-id's note on the remaining gap.
|
||||||
|
reply = client.get(f'/api/resolve_short_id/workflow/{self.owner_id}/1/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
|
def test_resolve_storage_location_as_owner(self):
|
||||||
|
reply = client.get(f'/api/resolve_short_id/storage_location/{self.owner_id}/{self.f["loc1"].id}/',
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['loc1'].id})
|
||||||
|
|
||||||
|
def test_resolve_storage_location_unknown_local_id(self):
|
||||||
|
reply = client.get(f'/api/resolve_short_id/storage_location/{self.owner_id}/999999/',
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_resolve_group_item_as_member(self):
|
||||||
|
item = InventoryItem.create_for_owner(
|
||||||
|
owner_group=self.f['group1'], owned_quantity=1, name='drill', availability_policy='private')
|
||||||
|
reply = client.get(f'/api/resolve_short_id/group_item/{self.f["group1"].pk}/{item.id}/',
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json(), {'handle': str(self.f['group1']), 'id': item.id})
|
||||||
|
|
||||||
|
def test_resolve_group_item_non_member_denied(self):
|
||||||
|
item = InventoryItem.create_for_owner(
|
||||||
|
owner_group=self.f['group1'], owned_quantity=1, name='drill', availability_policy='private')
|
||||||
|
reply = client.get(f'/api/resolve_short_id/group_item/{self.f["group1"].pk}/{item.id}/',
|
||||||
|
self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
def test_resolve_group_storage_location_as_member(self):
|
||||||
|
location = StorageLocation.create_for_owner(owner_group=self.f['group1'], name='shelf')
|
||||||
|
reply = client.get(
|
||||||
|
f'/api/resolve_short_id/group_storage_location/{self.f["group1"].pk}/{location.id}/',
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json(), {'handle': str(self.f['group1']), 'id': location.id})
|
||||||
|
|
||||||
|
def test_resolve_unknown_group(self):
|
||||||
|
reply = client.get('/api/resolve_short_id/group_item/999999/1/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
|
||||||
|
|
@ -260,98 +260,6 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(reply.status_code, 400)
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
class ResolveShortIdApiTestCase(UserTestMixin, InventoryTestMixin, GroupTestMixin, LocationTestMixin,
|
|
||||||
ToolshedTestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
self.prepare_users()
|
|
||||||
self.prepare_categories()
|
|
||||||
self.prepare_tags()
|
|
||||||
self.prepare_properties()
|
|
||||||
self.prepare_inventory()
|
|
||||||
self.prepare_groups()
|
|
||||||
self.prepare_locations()
|
|
||||||
self.owner_id = self.f['local_user1'].public_identity.pk
|
|
||||||
|
|
||||||
def test_resolve_item_as_owner(self):
|
|
||||||
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
|
||||||
self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 200)
|
|
||||||
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['item1'].id})
|
|
||||||
|
|
||||||
def test_resolve_item_as_friend(self):
|
|
||||||
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
|
||||||
self.f['local_user2'])
|
|
||||||
self.assertEqual(reply.status_code, 200)
|
|
||||||
|
|
||||||
def test_resolve_item_not_friend(self):
|
|
||||||
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
|
||||||
self.f['ext_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 403)
|
|
||||||
|
|
||||||
def test_resolve_item_private_not_owner(self):
|
|
||||||
private_item = InventoryItem.create_for_owner(
|
|
||||||
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
|
|
||||||
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
|
|
||||||
self.f['local_user2'])
|
|
||||||
self.assertEqual(reply.status_code, 403)
|
|
||||||
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
|
|
||||||
self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 200)
|
|
||||||
|
|
||||||
def test_resolve_item_unknown_owner(self):
|
|
||||||
reply = client.get('/api/resolve_short_id/item/999999/1/', self.f['local_user2'])
|
|
||||||
self.assertEqual(reply.status_code, 404)
|
|
||||||
|
|
||||||
def test_resolve_item_unknown_local_id(self):
|
|
||||||
reply = client.get(f'/api/resolve_short_id/item/{self.owner_id}/999999/', self.f['local_user2'])
|
|
||||||
self.assertEqual(reply.status_code, 404)
|
|
||||||
|
|
||||||
def test_resolve_unsupported_kind(self):
|
|
||||||
# workflow/category/file/group aren't wired up yet - see
|
|
||||||
# docs/handles-and-shortids.md#domain-qualified-short-id's note on the remaining gap.
|
|
||||||
reply = client.get(f'/api/resolve_short_id/workflow/{self.owner_id}/1/', self.f['local_user2'])
|
|
||||||
self.assertEqual(reply.status_code, 400)
|
|
||||||
|
|
||||||
def test_resolve_storage_location_as_owner(self):
|
|
||||||
reply = client.get(f'/api/resolve_short_id/storage_location/{self.owner_id}/{self.f["loc1"].id}/',
|
|
||||||
self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 200)
|
|
||||||
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['loc1'].id})
|
|
||||||
|
|
||||||
def test_resolve_storage_location_unknown_local_id(self):
|
|
||||||
reply = client.get(f'/api/resolve_short_id/storage_location/{self.owner_id}/999999/',
|
|
||||||
self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 404)
|
|
||||||
|
|
||||||
def test_resolve_group_item_as_member(self):
|
|
||||||
item = InventoryItem.create_for_owner(
|
|
||||||
owner_group=self.f['group1'], owned_quantity=1, name='drill', availability_policy='private')
|
|
||||||
reply = client.get(f'/api/resolve_short_id/group_item/{self.f["group1"].pk}/{item.id}/',
|
|
||||||
self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 200)
|
|
||||||
self.assertEqual(reply.json(), {'handle': str(self.f['group1']), 'id': item.id})
|
|
||||||
|
|
||||||
def test_resolve_group_item_non_member_denied(self):
|
|
||||||
item = InventoryItem.create_for_owner(
|
|
||||||
owner_group=self.f['group1'], owned_quantity=1, name='drill', availability_policy='private')
|
|
||||||
reply = client.get(f'/api/resolve_short_id/group_item/{self.f["group1"].pk}/{item.id}/',
|
|
||||||
self.f['local_user2'])
|
|
||||||
self.assertEqual(reply.status_code, 403)
|
|
||||||
|
|
||||||
def test_resolve_group_storage_location_as_member(self):
|
|
||||||
location = StorageLocation.create_for_owner(owner_group=self.f['group1'], name='shelf')
|
|
||||||
reply = client.get(
|
|
||||||
f'/api/resolve_short_id/group_storage_location/{self.f["group1"].pk}/{location.id}/',
|
|
||||||
self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 200)
|
|
||||||
self.assertEqual(reply.json(), {'handle': str(self.f['group1']), 'id': location.id})
|
|
||||||
|
|
||||||
def test_resolve_unknown_group(self):
|
|
||||||
reply = client.get('/api/resolve_short_id/group_item/999999/1/', self.f['local_user1'])
|
|
||||||
self.assertEqual(reply.status_code, 404)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, ToolshedTestCase):
|
class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, ToolshedTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
// Escapes `#` as `+` for use in a URL path segment. See docs/implementation.md#escaping-hash-in-handles-for-url-path-segments.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,7 +27,10 @@ import Scan from "@/views/Scan.vue";
|
||||||
import Workflows from '@/views/Workflows.vue';
|
import Workflows from '@/views/Workflows.vue';
|
||||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||||
import ShortId from '@/views/ShortId.vue';
|
import ShortId from '@/views/ShortId.vue';
|
||||||
import {encodeShortId, serializeShortId,decodeShortId, deserializeShortId} from '@/short-id';
|
import {
|
||||||
|
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
|
||||||
|
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||||
|
} from '@/short-id';
|
||||||
|
|
||||||
import Account from '@/views/settings/Account.vue';
|
import Account from '@/views/settings/Account.vue';
|
||||||
import Password from '@/views/settings/Password.vue';
|
import Password from '@/views/settings/Password.vue';
|
||||||
|
|
@ -46,33 +49,31 @@ export function decodeHandleFromUrl(segment) {
|
||||||
return segment.replace(/\+/g, "#");
|
return segment.replace(/\+/g, "#");
|
||||||
}
|
}
|
||||||
|
|
||||||
// item/group_item share this /inventory/:handle/:id shape; only owner-handle resolution (identity vs. group, see store.js) differs.
|
|
||||||
function itemDetailRoute(handle, item_local_id) {
|
|
||||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// storage_location/group_storage_location share this /storage-locations/:handle/:id shape, same idea as itemDetailRoute above.
|
|
||||||
function storageLocationDetailRoute(handle, storage_location_id) {
|
|
||||||
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ownerOverviewRoute(item) {
|
export function ownerOverviewRoute(item) {
|
||||||
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
|
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPANDED_ROUTE_BUILDERS = {
|
const EXPANDED_ROUTE_BUILDERS = {
|
||||||
item: ({owner_identity_id, item_local_id}) =>
|
item: ({owner_identity_id, item_local_id}) => {
|
||||||
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
|
const handle = store.getters.identityHandleById[owner_identity_id];
|
||||||
group_item: ({owner_group_id, item_local_id}) =>
|
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||||
itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id),
|
},
|
||||||
|
group_item: ({owner_group_id, item_local_id}) => {
|
||||||
|
const handle = store.getters.groupHandleById[owner_group_id];
|
||||||
|
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||||
|
},
|
||||||
group: ({group_id}) => {
|
group: ({group_id}) => {
|
||||||
const handle = store.getters.groupHandleById[group_id];
|
const handle = store.getters.groupHandleById[group_id];
|
||||||
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
|
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
|
||||||
},
|
},
|
||||||
storage_location: ({owner_identity_id, storage_location_id}) =>
|
storage_location: ({owner_identity_id, storage_location_id}) => {
|
||||||
storageLocationDetailRoute(store.getters.identityHandleById[owner_identity_id], storage_location_id),
|
const handle = store.getters.identityHandleById[owner_identity_id];
|
||||||
group_storage_location: ({owner_group_id, storage_location_id}) =>
|
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||||
storageLocationDetailRoute(store.getters.groupHandleById[owner_group_id], storage_location_id),
|
},
|
||||||
|
group_storage_location: ({owner_group_id, storage_location_id}) => {
|
||||||
|
const handle = store.getters.groupHandleById[owner_group_id];
|
||||||
|
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||||
|
},
|
||||||
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -88,6 +89,37 @@ export function shortenedRoute(named) {
|
||||||
return '/' + encodeShortId(serializeShortId(named));
|
return '/' + encodeShortId(serializeShortId(named));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Owned kinds only - matches handles-and-shortids.md's User-Qualified ID split (group/category/file have no owner id to hand a foreign backend).
|
||||||
|
export const OWNED_KIND_FIELDS = {
|
||||||
|
item: {ownerField: 'owner_identity_id', localField: 'item_local_id', isLocation: false},
|
||||||
|
group_item: {ownerField: 'owner_group_id', localField: 'item_local_id', isLocation: false},
|
||||||
|
storage_location: {ownerField: 'owner_identity_id', localField: 'storage_location_id', isLocation: true},
|
||||||
|
group_storage_location: {ownerField: 'owner_group_id', localField: 'storage_location_id', isLocation: true},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolves a Domain-Qualified Short ID to a route, locally or via a network round-trip. See docs/implementation.md#domain-qualified-short-id-resolution.
|
||||||
|
export async function domainQualifiedRoute(domain, ints) {
|
||||||
|
const homeDomain = store.getters.isLoggedIn && store.state.user ? store.state.user.split('@')[1] : null;
|
||||||
|
const deserialized = deserializeShortId(ints);
|
||||||
|
if (domain === homeDomain) {
|
||||||
|
return expandedRoute(deserialized) || undefined;
|
||||||
|
}
|
||||||
|
const owned = OWNED_KIND_FIELDS[deserialized.kind];
|
||||||
|
if (!owned) {
|
||||||
|
return undefined; // no owner id to resolve a foreign backend against yet (group/category/file/workflow)
|
||||||
|
}
|
||||||
|
const resolved = await store.dispatch('resolveShortId', {
|
||||||
|
domain, kind: deserialized.kind,
|
||||||
|
ownerId: deserialized[owned.ownerField], localId: deserialized[owned.localField],
|
||||||
|
});
|
||||||
|
if (!resolved || !resolved.handle) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return owned.isLocation
|
||||||
|
? `/storage-locations/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`
|
||||||
|
: `/inventory/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/inventory',
|
path: '/inventory',
|
||||||
component: Inventory,
|
component: Inventory,
|
||||||
|
|
@ -111,13 +143,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/:short_id',
|
path: '/:short_id',
|
||||||
component: ShortId,
|
component: ShortId,
|
||||||
props: true,
|
props: true,
|
||||||
beforeEnter: to => {
|
beforeEnter: async to => {
|
||||||
console.log(to);
|
if (isDomainQualifiedShortId(to.params.short_id)) {
|
||||||
const p = deserializeShortId(decodeShortId(to.params.short_id))
|
const {domain, ints} = decodeDomainQualifiedShortId(to.params.short_id);
|
||||||
console.log(p)
|
return await domainQualifiedRoute(domain, ints);
|
||||||
const url = expandedRoute(p);
|
}
|
||||||
console.log(url);
|
return expandedRoute(deserializeShortId(decodeShortId(to.params.short_id)));
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||||
path: '/friends',
|
path: '/friends',
|
||||||
|
|
@ -209,10 +240,6 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/storage-locations/new',
|
path: '/storage-locations/new',
|
||||||
component: StorageLocationNew,
|
component: StorageLocationNew,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
}, {
|
|
||||||
path: '/debug/:short_id',
|
|
||||||
component: ShortId,
|
|
||||||
props: true
|
|
||||||
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import {createStore} from 'vuex';
|
import {createStore} from 'vuex';
|
||||||
import router from '@/router';
|
import router, {encodeHandleForUrl} from '@/router';
|
||||||
import FallBackResolver from "@/dns";
|
import FallBackResolver from "@/dns";
|
||||||
import NeighborsCache from "@/neigbors";
|
import NeighborsCache from "@/neigbors";
|
||||||
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
|
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
|
||||||
|
|
@ -393,9 +393,9 @@ export default createStore({
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.get(getters.nullAuth, '/api/version/')
|
return await servers.get(getters.nullAuth, '/api/version/')
|
||||||
},
|
},
|
||||||
async fetchInventoryItems({commit, dispatch, getters}) {
|
async fetchInventoryItems({state, commit, dispatch, getters}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const items = await servers.get(getters.signAuth, '/api/inventory_items/')
|
const items = await servers.get(getters.signAuth, '/api/inventory_items/' + state.user + '/')
|
||||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||||
commit('setInventoryItems', {url: '/', items})
|
commit('setInventoryItems', {url: '/', items})
|
||||||
return items
|
return items
|
||||||
|
|
@ -404,11 +404,10 @@ export default createStore({
|
||||||
const servers = item.owner_group
|
const servers = item.owner_group
|
||||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||||
: await dispatch('getHomeServers')
|
: await dispatch('getHomeServers')
|
||||||
const data = {
|
const owner = item.owner_group ? encodeHandleForUrl(item.owner_group) : state.user
|
||||||
availability_policy: 'private', ...item,
|
const data = {availability_policy: 'private', ...item}
|
||||||
owner_group: item.owner_group ? item.owner_group.slice(1) : null
|
delete data.owner_group
|
||||||
}
|
const reply = await servers.post(getters.signAuth, '/api/inventory_items/' + owner + '/', data)
|
||||||
const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data)
|
|
||||||
state.last_load.files = 0
|
state.last_load.files = 0
|
||||||
return reply
|
return reply
|
||||||
},
|
},
|
||||||
|
|
@ -418,13 +417,16 @@ export default createStore({
|
||||||
: await dispatch('getHomeServers')
|
: await dispatch('getHomeServers')
|
||||||
const data = {availability_policy: 'friends', ...item}
|
const data = {availability_policy: 'friends', ...item}
|
||||||
data.files = data.files.map(file => file.id)
|
data.files = data.files.map(file => file.id)
|
||||||
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
|
// Path is scoped by the owner's handle, not the item's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
|
||||||
|
const path = '/api/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
|
||||||
|
return await servers.patch(getters.signAuth, path, data)
|
||||||
},
|
},
|
||||||
async deleteInventoryItem({state, dispatch, getters}, item) {
|
async deleteInventoryItem({state, dispatch, getters}, item) {
|
||||||
const servers = item.owner_group
|
const servers = item.owner_group
|
||||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||||
: await dispatch('getHomeServers')
|
: await dispatch('getHomeServers')
|
||||||
const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/')
|
const path = '/api/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
|
||||||
|
const ret = await servers.delete(getters.signAuth, path)
|
||||||
dispatch('fetchInventoryItems')
|
dispatch('fetchInventoryItems')
|
||||||
return ret
|
return ret
|
||||||
},
|
},
|
||||||
|
|
@ -458,8 +460,7 @@ export default createStore({
|
||||||
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
||||||
try {
|
try {
|
||||||
const servers = await dispatch('getFriendServers', {username: owner});
|
const servers = await dispatch('getFriendServers', {username: owner});
|
||||||
// owner is a full handle (username@domain); this endpoint looks the item up by
|
// owner is a full handle, looked up by owner not requester. See docs/implementation.md#owner-handle-scoped-routes.
|
||||||
// owner, not requester (see toolshed/api/inventory.py get_shared_item).
|
|
||||||
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
|
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
|
||||||
if (item && item.files) {
|
if (item && item.files) {
|
||||||
item.files.forEach(file => file.owner = item.owner)
|
item.files.forEach(file => file.owner = item.owner)
|
||||||
|
|
@ -541,6 +542,16 @@ export default createStore({
|
||||||
commit('setIdMap', idmap)
|
commit('setIdMap', idmap)
|
||||||
return idmap
|
return idmap
|
||||||
},
|
},
|
||||||
|
// See docs/implementation.md#domain-qualified-short-id-resolution.
|
||||||
|
async resolveShortId({dispatch, getters}, {domain, kind, ownerId, localId}) {
|
||||||
|
try {
|
||||||
|
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
|
return await servers.get(getters.signAuth, `/api/resolve_short_id/${kind}/${ownerId}/${localId}/`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to resolve short id (${kind}, ${ownerId}, ${localId}) on ${domain}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
// handle is "#name@domain".
|
// handle is "#name@domain".
|
||||||
async fetchGroup({dispatch, getters}, {handle}) {
|
async fetchGroup({dispatch, getters}, {handle}) {
|
||||||
const {domain} = splitGroupHandle(handle)
|
const {domain} = splitGroupHandle(handle)
|
||||||
|
|
@ -607,11 +618,11 @@ export default createStore({
|
||||||
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) {
|
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) {
|
||||||
const {domain} = splitGroupHandle(groupHandle)
|
const {domain} = splitGroupHandle(groupHandle)
|
||||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1))
|
const items = await servers.get(getters.signAuth, '/api/inventory_items/' + encodeHandleForUrl(groupHandle) + '/')
|
||||||
// Keyed by the full handle, not a bare id: a group pk is only unique within its own
|
// Keyed by the full handle, not a bare id: a group pk is only unique within its own
|
||||||
// backend's database, so two different domains could otherwise collide on the same
|
// backend's database, so two different domains could otherwise collide on the same
|
||||||
// item_map key - the handle already carries the domain, so no separate namespacing is needed.
|
// item_map key - the handle already carries the domain, so no separate namespacing is needed.
|
||||||
commit('setInventoryItems', {url: '/group/' + groupHandle, items})
|
commit('setInventoryItems', {url: '/' + groupHandle, items})
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
async fetchFiles({state, commit, dispatch, getters}) {
|
async fetchFiles({state, commit, dispatch, getters}) {
|
||||||
|
|
@ -713,15 +724,17 @@ export default createStore({
|
||||||
return state.storage_locations
|
return state.storage_locations
|
||||||
}
|
}
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.get(getters.signAuth, '/api/storage_locations/')
|
const data = await servers.get(getters.signAuth, '/api/storage_locations/' + state.user + '/')
|
||||||
commit('setStorageLocations', data)
|
commit('setStorageLocations', data)
|
||||||
state.last_load.storage_locations = Date.now()
|
state.last_load.storage_locations = Date.now()
|
||||||
return data
|
return data
|
||||||
},async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
|
},
|
||||||
|
async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
|
||||||
const {domain} = splitGroupHandle(groupHandle)
|
const {domain} = splitGroupHandle(groupHandle)
|
||||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
const locations = await servers.get(getters.signAuth, '/api/storage_locations/?group=' + groupHandle.slice(1))
|
const locations = await servers.get(
|
||||||
commit('setLocationsForKey', {url: '/group/' + groupHandle, locations})
|
getters.signAuth, '/api/storage_locations/' + encodeHandleForUrl(groupHandle) + '/')
|
||||||
|
commit('setLocationsForKey', {url: '/' + groupHandle, locations})
|
||||||
return locations
|
return locations
|
||||||
},
|
},
|
||||||
async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) {
|
async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) {
|
||||||
|
|
@ -741,7 +754,10 @@ export default createStore({
|
||||||
const servers = location.owner_group
|
const servers = location.owner_group
|
||||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||||
: await dispatch('getHomeServers')
|
: await dispatch('getHomeServers')
|
||||||
const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/')
|
// Path is scoped by the owner's handle, not the location's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
|
||||||
|
const path = '/api/storage_locations/' + encodeHandleForUrl(location.owner_group || location.owner) + '/' + location.id + '/'
|
||||||
|
const ret = await servers.delete(getters.signAuth, path)
|
||||||
|
state.last_load.storage_locations = 0
|
||||||
dispatch('fetchStorageLocations')
|
dispatch('fetchStorageLocations')
|
||||||
return ret
|
return ret
|
||||||
},
|
},
|
||||||
|
|
@ -751,9 +767,11 @@ export default createStore({
|
||||||
const servers = location.owner_group
|
const servers = location.owner_group
|
||||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||||
: await dispatch('getHomeServers')
|
: await dispatch('getHomeServers')
|
||||||
const data = {...location, owner_group: location.owner_group ? location.owner_group.slice(1) : null}
|
const owner = location.owner_group ? encodeHandleForUrl(location.owner_group) : state.user
|
||||||
|
const data = {...location}
|
||||||
|
delete data.owner_group
|
||||||
if (data.parent === '') data.parent = null
|
if (data.parent === '') data.parent = null
|
||||||
const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data)
|
const reply = await servers.post(getters.signAuth, '/api/storage_locations/' + owner + '/', data)
|
||||||
state.last_load.storage_locations = 0
|
state.last_load.storage_locations = 0
|
||||||
return reply
|
return reply
|
||||||
},
|
},
|
||||||
|
|
@ -763,7 +781,9 @@ export default createStore({
|
||||||
: await dispatch('getHomeServers')
|
: await dispatch('getHomeServers')
|
||||||
const data = {...location}
|
const data = {...location}
|
||||||
if (data.parent === '') data.parent = null
|
if (data.parent === '') data.parent = null
|
||||||
const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data)
|
const path = '/api/storage_locations/' + encodeHandleForUrl(location.owner_group || location.owner) + '/' + location.id + '/'
|
||||||
|
const reply = await servers.patch(getters.signAuth, path, data)
|
||||||
|
state.last_load.storage_locations = 0
|
||||||
dispatch('fetchStorageLocations')
|
dispatch('fetchStorageLocations')
|
||||||
return reply
|
return reply
|
||||||
},
|
},
|
||||||
|
|
@ -870,8 +890,8 @@ export default createStore({
|
||||||
inventory_items(state) {
|
inventory_items(state) {
|
||||||
return state.item_map['/'] || []
|
return state.item_map['/'] || []
|
||||||
},
|
},
|
||||||
groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [],
|
groupInventoryItems: (state) => (groupHandle) => state.item_map['/' + groupHandle] || [],
|
||||||
groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [],
|
groupStorageLocations: (state) => (groupHandle) => state.location_map['/' + groupHandle] || [],
|
||||||
identityIdByHandle(state) {
|
identityIdByHandle(state) {
|
||||||
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -98,16 +98,12 @@ export default {
|
||||||
decodedHandle() {
|
decodedHandle() {
|
||||||
return decodeHandleFromUrl(this.handle)
|
return decodeHandleFromUrl(this.handle)
|
||||||
},
|
},
|
||||||
// Am I a member of this group, hosted here (groupIdByHandle, from idmap) or elsewhere
|
// Client-side hint only; the backend hosting the group is what actually authorizes the write. See docs/implementation.md#group-membership-is-recorded-on-both-sides-like-friendship.
|
||||||
// (groupMemberships - see GroupMembership/docs/design-in-progress/groups-mvp.md)? Either
|
|
||||||
// way the backend actually hosting the group is what authorizes the write - this is just
|
|
||||||
// enough of a client-side hint to show/hide the buttons for it.
|
|
||||||
isGroupMember() {
|
isGroupMember() {
|
||||||
return this.decodedHandle in this.groupIdByHandle
|
return this.decodedHandle in this.groupIdByHandle
|
||||||
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
|
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
|
||||||
},
|
},
|
||||||
// Edit/Delete require actual authorization (own item or member group), not just view
|
// Edit/Delete require actual authorization; get_queryset()'s friends_or_self() lets a friend view but never act. See docs/implementation.md#owner-handle-scoped-routes.
|
||||||
// access - get_shared_item's friends_or_self() lets a friend view but never act.
|
|
||||||
canEdit() {
|
canEdit() {
|
||||||
return this.decodedHandle === this.user || this.isGroupMember
|
return this.decodedHandle === this.user || this.isGroupMember
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue