This commit is contained in:
j3d1 2026-08-22 23:29:02 +02:00
parent 3299c97392
commit de7d9426be
10 changed files with 259 additions and 99 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -40,7 +40,7 @@
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
<b-icon-trash></b-icon-trash>
</a>
<router-link :to="shortIdLink(item)">
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
<b-icon-link></b-icon-link>
</router-link>
</td>
@ -73,7 +73,8 @@
<router-link :to="`/inventory/${item.id}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<router-link :to="shortIdLink(item)" class="btn btn-secondary btn-sm">
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link>
</div>
@ -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()
}
}
</script>

View file

@ -19,6 +19,13 @@
</ul>
</div>
</div>
<div class="card">
<div class="card-header">Expanded</div>
<div class="card-body">
<router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link>
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
</div>
</div>
<div class="card">
<div class="card-header">Examples</div>
<table class="table table-striped mb-0">
@ -56,14 +63,16 @@
<script>
import BaseLayout from '@/components/BaseLayout.vue';
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
import {expandedRoute as buildExpandedRoute} from '@/router';
const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
{kind: 'group', group_id: 11},
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'file', file_id: 123},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
{kind: 'group', group_id: 11},
{kind: 'file', file_id: 123},
];
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
@ -130,6 +139,9 @@ export default {
const {kind, ...fields} = this.deserialized;
return fields;
},
expandedRoute() {
return buildExpandedRoute(this.deserialized);
},
examples() {
return EXAMPLES.map(named => {
const {kind, ...fields} = named;