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:
@@ -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 }} <strong>mime:</strong> {{ blobResult.mime }} <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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user