/**
* Attachment upload — the RPC (UDS-local / swarm-rpc) counterpart to the HTTP
* `POST /api/upload` web route. Before this, uploads only existed over HTTP and
* required an authenticated writer session, so there was no local/CLI path to
* add an attachment (issue #130). Both the web route and this handler now share
* `spoolAttachment()`: spool the bytes into the content-addressed attachment store
* and register them in the V2 overlay, which replicates them cross-daemon and
* serves them by content hash (`/files/raw?path=/attachments/<oid>`).
*/
import fs from '#node:fs'
import { join } from '#node:path'
import crypto from 'hypercore-crypto'
import { createStreamingHasher } from 'hyper-overlay'
import { requireWriter } from '../auth/check.js'
// Spool a source file into <_attachmentSpoolDir>/<oid> (oid = blake2b whole-file
// hash) and register it in the V2 overlay. Returns { hash, size, path }.
// `move:true` renames a throwaway tmp in; otherwise copies (a caller-owned file).
// Idempotent — identical content already spooled is reused; the spool IS canonical.
export async function spoolAttachment (svc, srcPath, { move = false } = {}) {
const spool = svc._attachmentSpoolDir
const size = fs.statSync(srcPath).size
if (size === 0) { const e = new Error('empty attachment'); e.status = 400; throw e }
const hasher = createStreamingHasher({ size })
for await (const buf of fs.createReadStream(srcPath)) hasher.update(buf)
const oid = hasher.digest()
const finalPath = join(spool, oid)
if (fs.existsSync(finalPath)) {
if (move) { try { fs.unlinkSync(srcPath) } catch {} } // dup — drop the redundant tmp
} else if (move) {
fs.renameSync(srcPath, finalPath)
} else {
fs.copyFileSync(srcPath, finalPath)
}
const path = '/attachments/' + oid
await svc.modules.overlayV2.registerFile(path, finalPath, { contentHash: oid, size })
return { hash: oid, size, path }
}
export function attachmentHandlers (svc) {
return [
{
// Upload an attachment. UDS-local caller = the daemon owner (trusted); over
// swarm-rpc a global writer (requireWriter enforces exactly that). Two modes:
// { path: <daemon-local abs path> } — the daemon reads the file directly (large
// files, no base64 blowup; the common local/CLI case over UDS).
// { data: <base64 | Buffer> } — inline bytes (small files / cross-machine callers).
name: 'upload-attachment',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, svc.identityRegistry)
const { data, path: srcPath } = params
const spool = svc._attachmentSpoolDir
if (data != null) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'base64')
const tmp = join(spool, '.tmp-' + crypto.randomBytes(8).toString('hex'))
fs.writeFileSync(tmp, buf)
try { return await spoolAttachment(svc, tmp, { move: true }) } finally {
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) } catch {}
}
}
if (srcPath) {
if (!fs.existsSync(srcPath)) throw new Error('upload-attachment: path not found: ' + srcPath)
return spoolAttachment(svc, srcPath, { move: false })
}
throw new Error('upload-attachment requires { path } (daemon-local file) or { data } (base64)')
}
}
]
}