OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / attachments.js

74 lines · javascript ref:
/**
 * 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)')
      }
    }
  ]
}