This commit is contained in:
j3d1 2026-08-17 18:28:01 +02:00
parent 25cef95711
commit 7d7730354e
8 changed files with 381 additions and 39 deletions

View file

@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.models import ToolshedUser, KnownIdentity
from authentication.signature_auth import SignatureAuthentication
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
from files.models import File
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
@ -76,6 +76,37 @@ def search_inventory_items(request):
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 (username@domain) and local id, e.g. for the
/i/<handle>/<id> item URL (see docs/design-in-progress/items-labels.md) or the
/inventory/shared/<handle>/<id> in-app view. Unlike InventoryItemViewSet, which only ever
returns the requester's own items, this looks the item up by owner instead of by requester,
so it's the only endpoint that can serve a friend's item - subject to the same
friends-or-self and availability_policy checks getUserProfile/_accessible_files already
use elsewhere."""
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):
serializer_class = StorageLocationSerializer
authentication_classes = [SignatureAuthentication]
@ -136,4 +167,5 @@ router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
urlpatterns = router.urls + [
path('search/', search_inventory_items, name='search_inventory_items'),
path('inventory_items/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
]

View file

@ -213,6 +213,47 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_get_shared_item_as_friend(self):
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['name'], 'test1')
self.assertEqual(reply.json()['owner'], 'testuser1@example.com')
def test_get_shared_item_as_owner(self):
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['name'], 'test1')
def test_get_shared_item_not_friend(self):
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
self.f['ext_user1'])
self.assertEqual(reply.status_code, 403)
def test_get_shared_item_private(self):
private_item = InventoryItem.objects.create(
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 403)
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_get_shared_item_unknown_handle(self):
reply = client.get('/api/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_get_shared_item_unknown_id(self):
reply = client.get('/api/inventory_items/testuser1@example.com/99999/', self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_get_shared_item_bad_handle(self):
reply = client.get('/api/inventory_items/testuser1/' + str(self.f['item1'].id) + '/', self.f['local_user2'])
self.assertEqual(reply.status_code, 400)
class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self):

View file

@ -21,3 +21,96 @@ their own public key to the friend's server. This way both users can access each
The protocol is based on a simple HTTPS API exchanging JSON data that is signed with the user's private key. By default
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).
## Unique Handles
Federation only works if every server can talk about the same thing without a central authority to ask. Toolshed's
answer is that every kind of entity that needs to be referenced across servers gets a handle: a name that is unique
within its own scope and that carries, as part of itself, enough information to say where it is authoritative. This
keeps servers independent of each other while still letting them agree on what they're talking about.
### Users (and Groups)
A user's handle is their username paired with the domain their account belongs to, written the way an email address
is, e.g. `user@toolsheddomain.tld`. Uniqueness is only required within a single domain, not across all of Toolshed,
so two different domains can each have their own "alice" without conflict, the same way two different email
providers can each have an "alice" mailbox. The domain half of the handle is what makes the name globally
unambiguous, and it is also what tells any other backend where to look to find out who's currently authoritative for
that identity, i.e. which backend holds the account and can vouch for its public key.
Groups aren't implemented yet, but they're intended to fit the same idea: a group would get its own handle on the
domain of the server that hosts it, the same way a user does, so that group membership and group-owned data could be
referenced by other servers without needing a separate mechanism. A group handle is written with a leading `#`, e.g.
`#groupname@toolsheddomain.tld`, so that group and user handles occupy visibly distinct spaces on the same domain
and a name can't be squatted as one to collide with the other. See [groups.md](design-in-progress/groups.md) for
details.
### Servers
The domain half of a handle, e.g. `toolsheddomain.tld`, is an authority record, not a location. Owning a domain
just means being able to say which backend is currently authoritative for handles under it; it says nothing about
where that backend is hosted, who operates it, or how many other domains it might also be authoritative for. A
single backend can just as easily host entities for one domain or for many unrelated ones at once, there's no
assumption anywhere in the model that a domain and a backend are the same thing, or that the relationship is one to
one.
The frontend application is a third, separate thing again. The app a user loads isn't necessarily served by, or
even related to, the backend that ends up handling their requests: when given a handle, the frontend looks up which
backend is currently authoritative for that handle's domain and talks to that backend directly from then on. So
using the frontend at one domain to log into a backend authoritative for a completely different domain isn't a
special case, it's the normal path, since "where the app was loaded from" and "which backend answers for a given
handle" were never the same question to begin with. Servers, in the cryptographic sense described below, don't have
an identity of their own beyond the handles they're currently authoritative for; a backend is, conceptually, just
wherever a given domain's handles happen to resolve to right now.
### Tags, Properties, and Categories
Inventory items aren't just described in free text, they can be classified with tags, properties, and categories,
and those get handles too, written as an origin followed by the kind and name, e.g. `origin#tag:drill` or
`origin#category:power-tools`. This lets the same short name (e.g. a "drill" tag) exist independently under
different origins without colliding, while a handle as a whole unambiguously says which taxonomy an entry belongs
to.
An origin isn't necessarily a server; it's whatever the classification is considered to have come from, which could
be a shared, canonical reference dataset that multiple servers import and reuse, just as easily as it could be a
server's own locally-invented taxonomy. This lets independently-run servers converge on a shared vocabulary where it
matters, without forcing every server to invent its own from scratch or requiring a central body to define one.
Handles are resolved strictly: a reference to an origin or entity a server doesn't know about is left unresolved
rather than being guessed at or silently merged into something that looks similar. This mirrors the rest of
Toolshed's federation philosophy, nothing is combined across servers implicitly; agreement always has to be
traceable to an explicit, shared handle.
### Items
Inventory items don't get a handle of their own the way tags or categories do, because they don't need one: every
item belongs to exactly one user, so a simple local identifier is already enough to tell two items apart within that
user's inventory. Combined with the owner's user handle, that local identifier is automatically unique across all of
Toolshed too, since no two users share a handle. Unlike a tag or category, an item isn't meant to be the same entity
reused across servers, it describes something one specific person actually owns, so there's no shared-origin concept
to design for here, ownership alone already provides the scope.
## Cryptography
Handles say who or what is being referred to; cryptography is what lets a server trust that the entity behind a
handle really is who it claims to be, without needing to ask a central authority. Every user handle has exactly one
asymmetric keypair backing it: a private key that never leaves the user's control, and a public key that gets handed
out freely as part of establishing that handle elsewhere.
A server first learns a public key at the moment it has reason to trust it: for its own users, that's registration;
for a friend's handle, that's the friend-request/accept exchange described above. From then on, a public key is
permanently paired with the handle it arrived with, never with a server. This is why friending is really a
key-exchange ceremony rather than just a social action, accepting a request is the moment a server starts trusting a
new handle's signature.
Every request made on a user's behalf is signed with that user's private key, and whichever server receives it
verifies the signature against the public key it holds for that handle. This is what makes it safe for a request to
travel to a server that isn't the user's home server: the receiving server doesn't need to trust the network path or
the sender, only the signature.
It's worth being explicit about what this layer of cryptography is for and what it isn't. Signing establishes
authenticity and integrity, that a request genuinely came from the handle it claims to, unaltered, not
confidentiality. The data itself isn't encrypted by the protocol; keeping it private in transit is what the
underlying HTTPS layer is for. Only user handles carry a keypair; tags, categories, properties, and item handles are
just names, their trustworthiness comes entirely from being reachable only through a signed request from the user
handle that owns or created them, not from any cryptographic identity of their own.

View file

@ -52,6 +52,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: InventoryDetailForeign,
meta: {requiresAuth: true, foreign: true},
props: true
}, {
path: '/i/:handle/:id',
redirect: to => {
const {handle, id} = to.params
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + handle + '/' + id
}
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
path: '/friends',
component: Friends,
@ -68,7 +74,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, {path: '/admin',
component: Admin,
meta: {requiresAuth: true}
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {path: '/print', component: Print, meta: {requiresAuth: true}}, {
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
path: '/print',
component: Print,
meta: {requiresAuth: true},
props: route => ({prefill: route.query.text})
}, {
path: '/search/:query',
component: Search,
meta: {requiresAuth: true},

View file

@ -412,6 +412,22 @@ export default createStore({
return null;
}
},
async fetchForeignItem({dispatch, getters}, {owner, id}) {
try {
const servers = await dispatch('getFriendServers', {username: owner});
// owner here is a full handle (username@domain) - see the /api/inventory_items/<handle>/<id>/
// endpoint (toolshed/api/inventory.py get_shared_item), which looks the item up by owner rather
// than by requester, unlike the plain /api/inventory_items/ list/detail endpoints.
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
if (item && item.files) {
item.files.forEach(file => file.owner = item.owner)
}
return item;
} catch (error) {
console.error(`Failed to fetch item ${id} for ${owner}:`, error);
return null;
}
},
async fetchFriendRequests({state, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
return await servers.get(getters.signAuth, '/api/friendrequests/')

View file

@ -46,6 +46,11 @@
<b-icon-trash></b-icon-trash>
Delete
</button>
<button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {text: itemUrl}})">
<b-icon-printer></b-icon-printer>
Print label
</button>
</div>
</div>
@ -75,12 +80,18 @@ export default {
},
computed: {
...mapGetters(["loaded_items", "getNameFromHandle"]),
...mapState(["storage_locations"]),
...mapState(["storage_locations", "user"]),
item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
},
location() {
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
},
itemUrl() {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what
// a printed label actually encodes, since scanning it has to resolve the right
// frontend/backend/item with no other context, not just this browser's history.
return `${window.location.origin}/i/${this.user}/${this.id}`
}
},
methods: {

View file

@ -6,9 +6,13 @@
<div class="card">
<div class="card-header">{{ item.name }}</div>
<div class="card-body">
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
{{ item.owner }}
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
Foreighn {{ item.description }}
{{ item.description }}
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
@ -36,18 +40,6 @@
</div>
</div>
</div>
<div class="card">
<button class="btn btn-primary" @click="$router.push('/inventory/' + id + '/edit')">
<b-icon-pencil-square></b-icon-pencil-square>
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
<b-icon-trash></b-icon-trash>
Delete
</button>
</div>
</div>
</div>
</main>
@ -57,38 +49,46 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import {mapActions, mapGetters} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
export default {
name: "InventoryDetail",
name: "InventoryDetailForeign",
components: {
AuthenticatedImage,
BaseLayout,
...BIcons
},
props: {
user: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
computed: {
...mapGetters(["loaded_items", "getNameFromHandle"]),
...mapState(["storage_locations"]),
item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
},
location() {
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
data() {
return {
item: {}
}
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]),
computed: {
...mapGetters(["getNameFromHandle"]),
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
methods: {
...mapActions(["fetchForeignItem"]),
async loadItem() {
this.item = await this.fetchForeignItem({owner: this.user, id: this.id}) || {}
}
},
watch: {
user: 'loadItem',
id: 'loadItem'
},
mounted() {
this.loadItem()
}
}
</script>

View file

@ -7,8 +7,51 @@
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!usbSupported" class="alert alert-warning">
This browser can't talk to USB label printers. Open this page in Chrome, Edge or Opera
over https:// (or http://localhost).
This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or
Opera over https:// (or http://localhost) to print straight from here, or use one of the
alternatives below.
</div>
<div v-if="!usbSupported" class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label preview</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="value">
<canvas ref="fallbackCanvas"></canvas>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download>
Download label as PNG
</button>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Print from the command line</h5>
</div>
<div class="card-body">
<p class="text-muted">
Download the PNG above, then send it to a label printer with its CLI tool.
</p>
<p class="mb-1"><strong>Brother QL-series</strong></p>
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
<p class="mb-1"><strong>Niimbot</strong></p>
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
</div>
</div>
</div>
</div>
<div v-else class="row">
@ -172,12 +215,49 @@ function drawQrLabel(canvas, qr, tape) {
}
}
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
plain square QR code, sized for a PNG someone downloads and prints some other way. */
function drawQrSquare(canvas, qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
const size = modules * FALLBACK_SCALE_PX;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
);
}
}
}
}
export default {
name: "Print",
components: {
BaseLayout,
...BIcons
},
props: {
// Prefilled from ?text= when arriving from e.g. an item's "Print label" button
// (see InventoryDetail.vue) - the router turns that query param into this prop
// (router.js's /print route), rather than the component reading $route directly.
prefill: {
type: String,
default: null
}
},
data() {
return {
usbSupported: true,
@ -188,8 +268,13 @@ export default {
devices: [],
connected: null,
value: "",
value: this.prefill || "",
copies: 1,
fallbackReady: false,
// TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
};
},
computed: {
@ -214,7 +299,11 @@ export default {
},
watch: {
value() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
},
methods: {
@ -299,6 +388,38 @@ export default {
this.fitZoom(canvas);
},
redrawFallback() {
this.fallbackReady = false;
if (!this.value) {
return;
}
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
try {
const qr = QRCode.create(this.value);
drawQrSquare(canvas, qr);
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.fallbackReady = true;
this.fitZoom(canvas);
},
downloadPng() {
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
const link = document.createElement("a");
link.download = "label.png";
link.href = canvas.toDataURL("image/png");
link.click();
},
/* Fit the preview to its card without ever needing a horizontal
scrollbar for a label this small, magnifying short labels up to
MAX_ZOOM rather than showing them at native (tiny) size. */
@ -322,7 +443,13 @@ export default {
handleResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.redraw(), 100);
this.resizeTimer = setTimeout(() => {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
}, 100);
},
onUsbChange() {
@ -336,8 +463,11 @@ export default {
this.resizeTimer = null;
},
async mounted() {
window.addEventListener("resize", this.handleResize);
if (!("usb" in navigator)) {
this.usbSupported = false;
await nextTick();
this.redrawFallback();
return;
}
try {
@ -349,7 +479,6 @@ export default {
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
window.addEventListener("resize", this.handleResize);
await this.guard(() => this.refreshDevices());
},
beforeUnmount() {
@ -381,4 +510,13 @@ export default {
.copies-input {
width: 5.5rem;
}
.code-block {
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
padding: .75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>