feat(blobs): HTTP upload+download routes and UI round-trip widget

Plumbs the BlobStore from blobs.rs into ApiHandler. The HMAC capability
key is derived from the node's Ed25519 signing key via a domain-separated
SHA-256 — rotating the identity rotates every outstanding cap (intentional
so a replaced node cannot honour old tokens).

New routes (added to nginx config in both server blocks):
- POST /api/blob — session-authenticated raw upload, returns
  {cid, size, mime, filename, self_test_url}. The self_test_url is a
  pre-signed cap pointing at the local node so the UI can verify the
  round-trip without needing a peer pubkey.
- GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey> — peer-facing,
  HMAC-verified in constant time, expiry-checked, then streams bytes.

Mesh.vue gets a minimal "Attachment test (blob store)" section: file
picker → upload → cid display → "Verify round-trip" and "Open in new
tab" buttons. This validates Phase 3a end-to-end before we layer the
ContentRef typed envelope variant on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 08:48:48 -04:00
parent 77eb1b907b
commit e8a729a4c7
4 changed files with 333 additions and 4 deletions

View File

@@ -374,6 +374,76 @@ function truncatePubkey(hex: string | null): string {
if (!hex) return ''
return hex.slice(0, 8) + '...' + hex.slice(-6)
}
// ── Blob store test (Phase 3a) ────────────────────────────────────────────
// Minimal widget to exercise POST /api/blob + GET /blob/<cid> with a
// self-signed capability. Validates the round-trip before we wire
// ContentRef typed-envelope sending.
interface BlobUploadResult {
cid: string
size: number
mime: string
filename: string | null
self_test_url: string
}
const blobUploading = ref(false)
const blobResult = ref<BlobUploadResult | null>(null)
const blobError = ref<string | null>(null)
const blobVerifyStatus = ref<string | null>(null)
async function handleBlobUpload(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
blobUploading.value = true
blobError.value = null
blobResult.value = null
blobVerifyStatus.value = null
try {
const buf = await file.arrayBuffer()
const resp = await fetch('/api/blob', {
method: 'POST',
headers: {
'X-Blob-Mime': file.type || 'application/octet-stream',
'X-Blob-Filename': file.name,
'Content-Type': 'application/octet-stream',
},
credentials: 'include',
body: buf,
})
if (!resp.ok) {
blobError.value = `upload failed: ${resp.status} ${await resp.text()}`
return
}
blobResult.value = await resp.json()
} catch (e) {
blobError.value = e instanceof Error ? e.message : 'upload failed'
} finally {
blobUploading.value = false
if (input) input.value = ''
}
}
async function verifyBlobRoundTrip() {
if (!blobResult.value) return
blobVerifyStatus.value = 'fetching...'
try {
const resp = await fetch(blobResult.value.self_test_url)
if (!resp.ok) {
blobVerifyStatus.value = `FAIL: ${resp.status} ${await resp.text()}`
return
}
const got = await resp.arrayBuffer()
const expected = blobResult.value.size
if (got.byteLength === expected) {
blobVerifyStatus.value = `OK — downloaded ${got.byteLength} bytes, CID verified`
} else {
blobVerifyStatus.value = `FAIL — got ${got.byteLength} bytes, expected ${expected}`
}
} catch (e) {
blobVerifyStatus.value = `FAIL: ${e instanceof Error ? e.message : 'unknown'}`
}
}
</script>
<template>
@@ -768,6 +838,32 @@ function truncatePubkey(hex: string | null): string {
<MeshDeadmanPanel v-if="showDeadmanPanel" />
</div>
</div>
<!-- Blob store round-trip test (Phase 3a) -->
<div class="glass-card" style="margin-top: 1rem; padding: 1rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<strong>Attachment test (blob store)</strong>
<span style="font-size: 0.8rem; opacity: 0.7;">Phase 3a upload, self-signed cap, round-trip verify</span>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
<label class="btn" style="cursor: pointer;">
<input type="file" @change="handleBlobUpload" style="display: none;" />
Pick file
</label>
<span v-if="blobUploading">uploading</span>
<span v-if="blobError" style="color: #f87171;">{{ blobError }}</span>
</div>
<div v-if="blobResult" style="margin-top: 0.75rem; font-family: monospace; font-size: 0.85rem;">
<div><strong>cid:</strong> {{ blobResult.cid }}</div>
<div><strong>size:</strong> {{ blobResult.size }} &nbsp; <strong>mime:</strong> {{ blobResult.mime }} &nbsp; <strong>filename:</strong> {{ blobResult.filename || '(none)' }}</div>
<div style="word-break: break-all;"><strong>url:</strong> {{ blobResult.self_test_url }}</div>
<div style="margin-top: 0.5rem; display: flex; gap: 0.75rem; align-items: center;">
<button class="btn" @click="verifyBlobRoundTrip">Verify round-trip</button>
<a :href="blobResult.self_test_url" target="_blank" class="btn">Open in new tab</a>
<span v-if="blobVerifyStatus" :style="{ color: blobVerifyStatus.startsWith('OK') ? '#4ade80' : '#f87171' }">{{ blobVerifyStatus }}</span>
</div>
</div>
</div>
</div>
</template>