diff --git a/backend/backend/urls.py b/backend/backend/urls.py index cb96927..495aac1 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -36,6 +36,7 @@ urlpatterns = [ path('admin/', include('hostadmin.api')), path('api/', include('toolshed.api.friend')), path('api/', include('toolshed.api.group')), + path('api/', include('toolshed.api.idmap')), path('api/', include('toolshed.api.inventory')), path('api/', include('toolshed.api.info')), path('api/', include('toolshed.api.files')), diff --git a/backend/toolshed/api/idmap.py b/backend/toolshed/api/idmap.py new file mode 100644 index 0000000..8051680 --- /dev/null +++ b/backend/toolshed/api/idmap.py @@ -0,0 +1,28 @@ +from django.urls import path +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSetMixin + +from authentication.models import KnownIdentity +from authentication.signature_auth import SignatureAuthentication +from toolshed.serializers import FriendSerializer, GroupIdMapSerializer + + +class IdMap(APIView, ViewSetMixin): + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, format=None): # /api/idmap/ + identity = request.user + identities = identity.friends.all() | KnownIdentity.objects.filter(pk=identity.pk) + groups = identity.member_of_groups.all() + return Response({ + 'identities': FriendSerializer(identities, many=True).data, + 'groups': GroupIdMapSerializer(groups, many=True).data, + }) + + +urlpatterns = [ + path('idmap/', IdMap.as_view(), name='idmap'), +] diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index dda7b50..da31585 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -92,6 +92,17 @@ class GroupSerializer(serializers.ModelSerializer): return str(obj) +class GroupIdMapSerializer(serializers.ModelSerializer): + handle = serializers.SerializerMethodField() + + class Meta: + model = Group + fields = ['id', 'name', 'domain', 'handle'] + + def get_handle(self, obj): + return str(obj) + + class GroupInviteIncomingSerializer(serializers.ModelSerializer): group = serializers.SerializerMethodField() inviter = serializers.SerializerMethodField() diff --git a/backend/toolshed/tests/test_idmap.py b/backend/toolshed/tests/test_idmap.py new file mode 100644 index 0000000..b0b288e --- /dev/null +++ b/backend/toolshed/tests/test_idmap.py @@ -0,0 +1,48 @@ +from django.test import Client + +from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase + +client = SignatureAuthClient() + + +class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_users() + + def test_idmap_includes_self(self): + reply = client.get('/api/idmap/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + identities = reply.json()['identities'] + self.assertIn(str(self.f['local_user1']), [i['username'] for i in identities]) + + def test_idmap_includes_friends(self): + self.f['local_user1'].friends.add(self.f['local_user2'].public_identity) + reply = client.get('/api/idmap/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + identities = reply.json()['identities'] + self.assertIn(str(self.f['local_user2']), [i['username'] for i in identities]) + + def test_idmap_excludes_non_friends(self): + reply = client.get('/api/idmap/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + identities = reply.json()['identities'] + self.assertNotIn(str(self.f['local_user2']), [i['username'] for i in identities]) + + def test_idmap_includes_member_groups(self): + self.prepare_groups() + reply = client.get('/api/idmap/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + groups = reply.json()['groups'] + self.assertIn(str(self.f['group1']), [g['handle'] for g in groups]) + + def test_idmap_excludes_non_member_groups(self): + self.prepare_groups() + reply = client.get('/api/idmap/', self.f['local_user2']) + self.assertEqual(reply.status_code, 200) + groups = reply.json()['groups'] + self.assertNotIn(str(self.f['group1']), [g['handle'] for g in groups]) + + def test_idmap_unauthenticated(self): + reply = Client().get('/api/idmap/') + self.assertEqual(reply.status_code, 403) diff --git a/frontend/src/router.js b/frontend/src/router.js index 2f9d0b1..f070d8e 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -28,6 +28,7 @@ import Scan from "@/views/Scan.vue"; import Workflows from '@/views/Workflows.vue'; import WorkflowDetail from '@/views/WorkflowDetail.vue'; import ShortId from '@/views/ShortId.vue'; +import {encodeShortId, serializeShortId,decodeShortId, deserializeShortId} from '@/short-id'; import Account from '@/views/settings/Account.vue'; import Password from '@/views/settings/Password.vue'; @@ -46,6 +47,23 @@ export function decodeHandleFromUrl(segment) { return segment.replace(/\+/g, "#"); } +const EXPANDED_ROUTE_BUILDERS = { + item: ({item_local_id}) => `/inventory/${item_local_id}`, + group_item: ({item_local_id}) => `/inventory/${item_local_id}`, + group: ({group_id}) => `/groups/${group_id}`, + storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`, + workflow: ({workflow_id}) => `/workflows/${workflow_id}`, +}; + +export function expandedRoute({kind, ...fields}) { + const buildRoute = EXPANDED_ROUTE_BUILDERS[kind]; + return buildRoute ? buildRoute(fields) : null; +} + +export function shortenedRoute(named) { + return '/' + encodeShortId(serializeShortId(named)); +} + const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { path: '/inventory', component: Inventory, @@ -72,6 +90,16 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { const {id} = to.params return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id } +}, { + path: '/:short_id', + redirect: to => { + console.log(to) + const p = deserializeShortId(decodeShortId(to.params.short_id)) + console.log(p) + const url = expandedRoute(p) + console.log(url) + return url; + } }, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, { path: '/friends', component: Friends, @@ -161,9 +189,14 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { component: StorageLocationNew, meta: {requiresAuth: true} }, { - path: '/:short_id', + path: '/debug/:short_id', component: ShortId, props: true +}, { + path: '/:short_id', + redirect: to => { + + } }, {path: '/:pathMatch(.*)*', redirect: '/'}] const router = createRouter({ diff --git a/frontend/src/short-id.js b/frontend/src/short-id.js index 2ef788b..f26e4f7 100644 --- a/frontend/src/short-id.js +++ b/frontend/src/short-id.js @@ -8,31 +8,37 @@ const SCHEMAS = { fields: ['owner_identity_id', 'item_local_id'], interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id}) }, +//0-1 left free for later use '1-2': { + name: 'group_item', + fields: ['owner_group_id', 'item_local_id'], + interpret: ([owner_group_id, item_local_id]) => ({kind: 'group_item', owner_group_id, item_local_id}) + }, + '1-1': { + name: 'group', + fields: ['group_id'], + interpret: ([group_id]) => ({kind: 'group', group_id}) + }, + '2-2': { name: 'storage_location', fields: ['owner_identity_id', 'storage_location_id'], interpret: ([owner_identity_id, storage_location_id]) => ({kind: 'storage_location', owner_identity_id, storage_location_id}) }, '2-1': { - name: 'category', - fields: ['category_id'], - interpret: ([category_id]) => ({kind: 'category', category_id}) + name: 'file', + fields: ['file_id'], + interpret: ([file_id]) => ({kind: 'file', file_id}) }, '3-2': { name: 'workflow', fields: ['owner_identity_id', 'workflow_id'], interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id}) }, - '4-1': { - name: 'group', - fields: ['group_id'], - interpret: ([group_id]) => ({kind: 'group', group_id}) - }, - '5-1': { - name: 'file', - fields: ['file_id'], - interpret: ([file_id]) => ({kind: 'file', file_id}) + '3-1': { + name: 'category', + fields: ['category_id'], + interpret: ([category_id]) => ({kind: 'category', category_id}) }, } @@ -82,6 +88,10 @@ class BitReader { } return value } + + bitsLeft() { + return this.bits.length - this.pos + } } function base64UrlToBits(text) { @@ -128,25 +138,6 @@ function readChunkedInt(reader, chunkBits) { } } -function entriesForKindIndex(kindIndex) { - return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`)) -} - -function schemaForKindIndex(kindIndex) { - const matches = entriesForKindIndex(kindIndex) - if (matches.length === 0) { - throw new Error(`unknown short id kind index: ${kindIndex}`) - } - if (matches.length > 1) { - throw new Error( - `ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one ` - + `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} ` - + `- only deserializeShortId's (kind, fields.length) lookup can tell those apart` - ) - } - return matches[0][1] -} - function schemaForKindAndArity(kindIndex, arity) { const schema = SCHEMAS[`${kindIndex}-${arity}`] if (!schema) { @@ -160,10 +151,12 @@ export function encodeShortId(ints) { throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]') } const [kindIndex, ...fieldValues] = ints - const schema = schemaForKindIndex(kindIndex) - if (fieldValues.length !== schema.fields.length) { + const schema = schemaForKindAndArity(kindIndex, fieldValues.length) + if (fieldValues[fieldValues.length - 1] === 0) { throw new Error( - `short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}` + `short id kind index ${kindIndex} ('${schema.name}'): the last field's value must not be 0 - ` + + `reserved so decodeShortId can tell a real trailing field apart from zero-padding without ` + + `knowing the kind's arity up front (see docs/handles-and-shortids.md)` ) } const writer = new BitWriter() @@ -188,8 +181,14 @@ export function decodeShortId(token) { if (kindIndex === DIRECT_KIND_COUNT) { kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS)) } - const schema = schemaForKindIndex(kindIndex) - const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS))) + const fieldValues = [] + while (reader.bitsLeft() >= CHUNK_BITS + 1) { + const value = readChunkedInt(reader, CHUNK_BITS) + if (value === 0n && reader.bitsLeft() === 0) { + break // a trailing all-zero chunk with nothing after it is padding, not a real field + } + fieldValues.push(Number(value)) + } return [kindIndex, ...fieldValues] } diff --git a/frontend/src/store.js b/frontend/src/store.js index 33d0cd5..9e3198a 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -74,6 +74,7 @@ export default createStore({ friendProfiles: {}, groups: [], groupInvites: [], + idmap: {identities: [], groups: []}, item_map: {}, home_servers: null, all_friends_servers: null, @@ -112,6 +113,9 @@ export default createStore({ setGroupInvites(state, invites) { state.groupInvites = invites; }, + setIdMap(state, idmap) { + state.idmap = idmap; + }, setHomeServers(state, home_servers) { state.home_servers = home_servers; }, @@ -498,6 +502,12 @@ export default createStore({ commit('setGroups', data) return data }, + async fetchIdMap({commit, dispatch, getters}) { + const servers = await dispatch('getHomeServers') + const idmap = await servers.get(getters.signAuth, '/api/idmap/') + commit('setIdMap', idmap) + return idmap + }, async fetchGroup({dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/groups/' + id + '/') @@ -786,6 +796,12 @@ export default createStore({ return state.item_map['/'] || [] }, groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [], + identityIdByHandle(state) { + return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id])) + }, + groupIdByHandle(state) { + return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id])) + }, loaded_items(state) { return Object.entries(state.item_map).reduce((acc, [url, items]) => { return acc.concat(items) diff --git a/frontend/src/tests/short-id.js b/frontend/src/tests/short-id.js index 6ba5e4a..d189a68 100644 --- a/frontend/src/tests/short-id.js +++ b/frontend/src/tests/short-id.js @@ -5,23 +5,60 @@ test('encodes the worked example from docs/handles-and-shortids.md', () => { expect(token).toBe('~DyU') }) -test('round-trips every kind as a flat [kindIndex, ...fieldValues] list', () => { +test('kind ids are scoped per arity: id 0 means item (arity 2) or category (arity 1)', () => { + expect(deserializeShortId(decodeShortId(encodeShortId([0, 7, 42])))).toEqual({ + kind: 'item', owner_identity_id: 7, item_local_id: 42 + }) + expect(deserializeShortId(decodeShortId(encodeShortId([0, 5])))).toEqual({ + kind: 'category', category_id: 5 + }) +}) + +test('every kind round-trips through serialize/encode/decode/deserialize', () => { const cases = [ - [0, 7, 42], - [1, 3, 1000], - [2, 0], + {kind: 'item', owner_identity_id: 7, item_local_id: 42}, + {kind: 'category', category_id: 5}, + {kind: 'group_item', owner_group_id: 5, item_local_id: 42}, + {kind: 'group', group_id: 11}, + {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, + {kind: 'file', file_id: 123}, + {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, ] - for (const ints of cases) { - const token = encodeShortId(ints) - expect(decodeShortId(token)).toEqual(ints) + for (const named of cases) { + const token = encodeShortId(serializeShortId(named)) + expect(deserializeShortId(decodeShortId(token))).toEqual(named) } }) -test('round-trips small and large values, including zero', () => { - const values = [0, 1, 7, 42, 100, 10000, 100000, 123456789] +test('a non-last field may be 0 - only the last field is restricted', () => { + const named = {kind: 'item', owner_identity_id: 0, item_local_id: 42} + const token = encodeShortId(serializeShortId(named)) + expect(deserializeShortId(decodeShortId(token))).toEqual(named) +}) + +test('rejects a 0 last-field value, for a single-field kind', () => { + expect(() => encodeShortId(serializeShortId({kind: 'category', category_id: 0}))).toThrow() +}) + +test('rejects a 0 last-field value, for a multi-field kind', () => { + expect(() => encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 0}))).toThrow() +}) + +test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' + + 'mistaken for a second field', () => { + // category_id: 5 encodes as kind-tag(2 bits) + one 5-bit chunk = 7 bits, padded with exactly + // 5 zero bits - the same 5 bits as a genuine one-chunk field of value 0. This is the concrete + // case the last-field-nonzero rule exists to disambiguate. + const token = encodeShortId([0, 5]) + expect(token).toBe('~Cg') + expect(decodeShortId(token)).toEqual([0, 5]) +}) + +test('round-trips small and large nonzero values, in both last and non-last field position', () => { + const values = [1, 7, 42, 100, 10000, 100000, 123456789] for (const value of values) { - const token = encodeShortId([2, value]) - expect(decodeShortId(token)).toEqual([2, value]) + expect(decodeShortId(encodeShortId([0, value]))).toEqual([0, value]) + expect(decodeShortId(encodeShortId([0, value, 1]))).toEqual([0, value, 1]) } }) @@ -31,30 +68,11 @@ test('tokens are URL-safe: only unreserved characters, plus the leading ~', () = expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/) }) -test('deserializeShortId names a decoded list into a flat shape, serializeShortId reverses it', () => { - const cases = [ - [[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42}], - [[1, 3, 1000], {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}], - [[2, 0], {kind: 'category', category_id: 0}], - [[3, 2, 9], {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}], - [[4, 11], {kind: 'group', group_id: 11}], - [[5, 123], {kind: 'file', file_id: 123}], - ] - for (const [ints, named] of cases) { - expect(deserializeShortId(ints)).toEqual(named) - expect(serializeShortId(named)).toEqual(ints) - } -}) - -test('kinds 3+ round-trip through the escape tag, not just the direct 0-2 range', () => { - for (const named of [ - {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, - {kind: 'group', group_id: 11}, - {kind: 'file', file_id: 123}, - ]) { - const token = encodeShortId(serializeShortId(named)) - expect(deserializeShortId(decodeShortId(token))).toEqual(named) - } +test('kinds at escaped ids (3+) round-trip through the escape tag, not just the direct 0-2 range', () => { + const token = encodeShortId(serializeShortId({kind: 'workflow', owner_identity_id: 2, workflow_id: 9})) + expect(deserializeShortId(decodeShortId(token))).toEqual({ + kind: 'workflow', owner_identity_id: 2, workflow_id: 9 + }) }) test('serializeShortId then deserializeShortId round-trips through encode/decode', () => { @@ -75,30 +93,19 @@ test('rejects an unknown kind index', () => { expect(() => encodeShortId([99, 1])).toThrow() }) -test('deserializeShortId is keyed by (kind, fields.length), not kind alone', () => { - // kind=1 with the arity storage_location actually has (2 fields) is fine... - expect(deserializeShortId([1, 3, 1000])).toEqual({ - kind: 'storage_location', - owner_identity_id: 3, - storage_location_id: 1000 - }) - // ...but kind=1 with a different arity (1 field) isn't a registered schema at all, and must - // not be silently misread as a truncated storage_location. - expect(() => deserializeShortId([1, 359])).toThrow() +test('rejects an arity with no registered schema at that kind index', () => { + // kind 0 is registered for arity 2 (item) and arity 1 (category), but not arity 3. + expect(() => encodeShortId([0, 7, 8, 9])).toThrow() }) test('rejects an unknown kind name', () => { expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow() }) -test('rejects a field count mismatch', () => { - expect(() => encodeShortId([0, 7])).toThrow() -}) - test('rejects a missing field', () => { expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow() }) test('rejects a negative field value', () => { - expect(() => encodeShortId([2, -1])).toThrow() + expect(() => encodeShortId([0, -1, 42])).toThrow() }) diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 46d228c..d157a3a 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -40,7 +40,7 @@ - + @@ -73,7 +73,8 @@ Edit - + @@ -100,7 +101,7 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex"; import * as BIcons from "bootstrap-icons-vue"; import BaseLayout from "@/components/BaseLayout.vue"; import AuthenticatedImage from "../components/AuthenticatedImage.vue"; -import {encodeShortId, serializeShortId} from "@/short-id"; +import {shortenedRoute} from "@/router"; export default { name: "Inventory", @@ -115,11 +116,11 @@ export default { ...BIcons }, computed: { - ...mapGetters(["inventory_items", "loaded_items"]), + ...mapGetters(["inventory_items", "loaded_items", "identityIdByHandle", "groupIdByHandle"]), ...mapState(["user", "storage_locations"]), }, methods: { - ...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]), + ...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]), only_images(files) { return files.filter(file => file.mime_type.startsWith("image/")); }, @@ -128,16 +129,20 @@ export default { return loc ? loc.path : null }, shortIdLink(item) { - // owner_identity_id is a placeholder until the backend exposes a KnownIdentity pk - // per item (see docs/handles-and-shortids.md) - it doesn't decode to a - // meaningful owner yet. - const ints = serializeShortId({kind: 'item', owner_identity_id: 0, item_local_id: item.id}); - return '/' + encodeShortId(ints); + if (item.owner_group) { + const owner_group_id = this.groupIdByHandle[item.owner_group] + if (owner_group_id === undefined) return null + return shortenedRoute({kind: 'group_item', owner_group_id, item_local_id: item.id}) + } + const owner_identity_id = this.identityIdByHandle[item.owner] + if (owner_identity_id === undefined) return null + return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id}) }, }, async mounted() { await this.fetchInventoryItems() await this.fetchStorageLocations() + await this.fetchIdMap() } } diff --git a/frontend/src/views/ShortId.vue b/frontend/src/views/ShortId.vue index 2347018..a41e95d 100644 --- a/frontend/src/views/ShortId.vue +++ b/frontend/src/views/ShortId.vue @@ -19,6 +19,13 @@ + + Expanded + + {{ expandedRoute }} + No page exists for kind '{{ deserialized.kind }}' + + Examples @@ -56,14 +63,16 @@