From 7d7730354ea0d55c7ecf13e8ed0dcf48804509b0 Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 17 Aug 2026 18:28:01 +0200 Subject: [PATCH] stash --- backend/toolshed/api/inventory.py | 34 +++- backend/toolshed/tests/test_inventory.py | 41 +++++ docs/federation.md | 95 ++++++++++- frontend/src/router.js | 13 +- frontend/src/store.js | 16 ++ frontend/src/views/InventoryDetail.vue | 13 +- frontend/src/views/InventoryDetailForeign.vue | 58 +++---- frontend/src/views/Print.vue | 150 +++++++++++++++++- 8 files changed, 381 insertions(+), 39 deletions(-) diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index e28e296..07a415b 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -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// item URL (see docs/design-in-progress/items-labels.md) or the + /inventory/shared// 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///', get_shared_item, name='shared_inventory_item'), ] diff --git a/backend/toolshed/tests/test_inventory.py b/backend/toolshed/tests/test_inventory.py index a05421c..fc7e5f2 100644 --- a/backend/toolshed/tests/test_inventory.py +++ b/backend/toolshed/tests/test_inventory.py @@ -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): diff --git a/docs/federation.md b/docs/federation.md index d242677..5122c0a 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -20,4 +20,97 @@ uses it to verify access to the friend's inventory. While accepting a friend req their own public key to the friend's server. This way both users can access each other's inventory. 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). \ No newline at end of file +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. \ No newline at end of file diff --git a/frontend/src/router.js b/frontend/src/router.js index 30a99b2..0cf663c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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}, diff --git a/frontend/src/store.js b/frontend/src/store.js index 5effaa0..560dee2 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -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/// + // 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/') diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 31952f2..780afdb 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -46,6 +46,11 @@ Delete + @@ -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: { diff --git a/frontend/src/views/InventoryDetailForeign.vue b/frontend/src/views/InventoryDetailForeign.vue index 29513bd..313ffa6 100644 --- a/frontend/src/views/InventoryDetailForeign.vue +++ b/frontend/src/views/InventoryDetailForeign.vue @@ -6,9 +6,13 @@
{{ item.name }}
+
+ + {{ item.owner }} +
- Foreighn {{ item.description }} + {{ item.description }}
@@ -36,18 +40,6 @@
-
- - -
- @@ -57,38 +49,46 @@ @@ -99,4 +99,4 @@ img { height: 107px; object-fit: contain; } - \ No newline at end of file + diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index 28d0908..751c315 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -7,8 +7,51 @@
- 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. +
+ +
+
+
+
+
Label preview
+
+
+
+ + +
+ +
+ +
+ + +
+
+
+
+
+
+
Print from the command line
+
+
+

+ Download the PNG above, then send it to a label printer with its CLI tool. +

+

Brother QL-series

+
{{ brotherCliExample }}
+

Niimbot

+
{{ niimbotCliExample }}
+
+
+
@@ -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() { - this.redraw(); + 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; +}