This commit is contained in:
j3d1 2026-08-27 02:51:30 +02:00
parent 6ee5ae38b1
commit 9685b4020e
11 changed files with 331 additions and 268 deletions

View file

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

View file

@ -27,7 +27,10 @@ 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 {
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
isDomainQualifiedShortId, decodeDomainQualifiedShortId
} from '@/short-id';
import Account from '@/views/settings/Account.vue';
import Password from '@/views/settings/Password.vue';
@ -46,33 +49,31 @@ export function decodeHandleFromUrl(segment) {
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) {
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
}
const EXPANDED_ROUTE_BUILDERS = {
item: ({owner_identity_id, item_local_id}) =>
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
group_item: ({owner_group_id, item_local_id}) =>
itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id),
item: ({owner_identity_id, item_local_id}) => {
const handle = store.getters.identityHandleById[owner_identity_id];
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
},
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}) => {
const handle = store.getters.groupHandleById[group_id];
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
},
storage_location: ({owner_identity_id, storage_location_id}) =>
storageLocationDetailRoute(store.getters.identityHandleById[owner_identity_id], storage_location_id),
group_storage_location: ({owner_group_id, storage_location_id}) =>
storageLocationDetailRoute(store.getters.groupHandleById[owner_group_id], storage_location_id),
storage_location: ({owner_identity_id, storage_location_id}) => {
const handle = store.getters.identityHandleById[owner_identity_id];
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
},
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}`,
};
@ -88,6 +89,37 @@ export function shortenedRoute(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}}, {
path: '/inventory',
component: Inventory,
@ -111,13 +143,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/:short_id',
component: ShortId,
props: true,
beforeEnter: to => {
console.log(to);
const p = deserializeShortId(decodeShortId(to.params.short_id))
console.log(p)
const url = expandedRoute(p);
console.log(url);
return url;
beforeEnter: async to => {
if (isDomainQualifiedShortId(to.params.short_id)) {
const {domain, ints} = decodeDomainQualifiedShortId(to.params.short_id);
return await domainQualifiedRoute(domain, ints);
}
return expandedRoute(deserializeShortId(decodeShortId(to.params.short_id)));
}
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
path: '/friends',
@ -209,10 +240,6 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/storage-locations/new',
component: StorageLocationNew,
meta: {requiresAuth: true}
}, {
path: '/debug/:short_id',
component: ShortId,
props: true
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
const router = createRouter({

View file

@ -1,5 +1,5 @@
import {createStore} from 'vuex';
import router from '@/router';
import router, {encodeHandleForUrl} from '@/router';
import FallBackResolver from "@/dns";
import NeighborsCache from "@/neigbors";
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
@ -393,9 +393,9 @@ export default createStore({
const servers = await dispatch('getHomeServers')
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 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))
commit('setInventoryItems', {url: '/', items})
return items
@ -404,11 +404,10 @@ export default createStore({
const servers = item.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers')
const data = {
availability_policy: 'private', ...item,
owner_group: item.owner_group ? item.owner_group.slice(1) : null
}
const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data)
const owner = item.owner_group ? encodeHandleForUrl(item.owner_group) : state.user
const data = {availability_policy: 'private', ...item}
delete data.owner_group
const reply = await servers.post(getters.signAuth, '/api/inventory_items/' + owner + '/', data)
state.last_load.files = 0
return reply
},
@ -418,13 +417,16 @@ export default createStore({
: await dispatch('getHomeServers')
const data = {availability_policy: 'friends', ...item}
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) {
const servers = item.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: 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')
return ret
},
@ -458,8 +460,7 @@ export default createStore({
async fetchForeignItem({dispatch, getters}, {owner, id}) {
try {
const servers = await dispatch('getFriendServers', {username: owner});
// owner is a full handle (username@domain); this endpoint looks the item up by
// owner, not requester (see toolshed/api/inventory.py get_shared_item).
// owner is a full handle, looked up by owner not requester. See docs/implementation.md#owner-handle-scoped-routes.
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
if (item && item.files) {
item.files.forEach(file => file.owner = item.owner)
@ -541,6 +542,16 @@ export default createStore({
commit('setIdMap', 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".
async fetchGroup({dispatch, getters}, {handle}) {
const {domain} = splitGroupHandle(handle)
@ -607,11 +618,11 @@ export default createStore({
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) {
const {domain} = splitGroupHandle(groupHandle)
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
// 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.
commit('setInventoryItems', {url: '/group/' + groupHandle, items})
commit('setInventoryItems', {url: '/' + groupHandle, items})
return items
},
async fetchFiles({state, commit, dispatch, getters}) {
@ -713,15 +724,17 @@ export default createStore({
return state.storage_locations
}
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)
state.last_load.storage_locations = Date.now()
return data
},async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
},
async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
const {domain} = splitGroupHandle(groupHandle)
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const locations = await servers.get(getters.signAuth, '/api/storage_locations/?group=' + groupHandle.slice(1))
commit('setLocationsForKey', {url: '/group/' + groupHandle, locations})
const locations = await servers.get(
getters.signAuth, '/api/storage_locations/' + encodeHandleForUrl(groupHandle) + '/')
commit('setLocationsForKey', {url: '/' + groupHandle, locations})
return locations
},
async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) {
@ -741,7 +754,10 @@ export default createStore({
const servers = location.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
: 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')
return ret
},
@ -751,9 +767,11 @@ export default createStore({
const servers = location.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
: 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
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
return reply
},
@ -763,7 +781,9 @@ export default createStore({
: await dispatch('getHomeServers')
const data = {...location}
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')
return reply
},
@ -870,8 +890,8 @@ export default createStore({
inventory_items(state) {
return state.item_map['/'] || []
},
groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [],
groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [],
groupInventoryItems: (state) => (groupHandle) => state.item_map['/' + groupHandle] || [],
groupStorageLocations: (state) => (groupHandle) => state.location_map['/' + groupHandle] || [],
identityIdByHandle(state) {
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
},

View file

@ -98,16 +98,12 @@ export default {
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
// Am I a member of this group, hosted here (groupIdByHandle, from idmap) or elsewhere
// (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.
// 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.
isGroupMember() {
return this.decodedHandle in this.groupIdByHandle
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
},
// Edit/Delete require actual authorization (own item or member group), not just view
// access - get_shared_item's friends_or_self() lets a friend view but never act.
// 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.
canEdit() {
return this.decodedHandle === this.user || this.isGroupMember
},