Files
custo-viewer/prune-blobs.js
T
ops f7f1c30eae serveBlob: only serve blobs referenced by feeds we replicate
serveBlob handed any held blob to anyone who knew its hash, on a public
port with require_opt_in:false. Combined with ssb-blobs sympathy that
meant publicly serving 4.6GB of strangers content we never reviewed.

Maintains a set of blob ids referenced by the local log (small, ~360KB,
rescanned every 5 min) and 404s anything outside it. Fails open until the
first scan completes so a read error cannot 404 the whole site.

Verified: a blob present on disk but referenced by no message returns
blobs.has=true over RPC while the viewer 404s it.
2026-08-14 20:13:02 +00:00

164 lines
5.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
//
// prune-blobs.js — delete cached blobs that none of our own feeds reference.
//
// CONTEXT
// -------
// ssb-blobs `sympathy` (default 3) made this node fetch and cache blobs on
// behalf of any peer up to 3 network hops away. With port 8008 open to the
// world, that turned it into a free CDN: 31,283 blobs / 4.6 GB, of which only
// 267 are referenced by any message we hold.
//
// Once sympathy is 0 the inflow stops, but the existing hoard stays on disk --
// and ssb-viewer will serve any of it by hash to anyone who asks. This removes
// it.
//
// SAFETY
// ------
// * Dry run by default. Pass --delete to actually remove anything.
// * Every deleted id is written to a manifest first. Blobs are content-
// addressed, so anything deleted in error can be re-requested with
// `ssb-server blobs.want <id>` if a peer still has it.
// * The keep set is built from the raw log, so it does not depend on sbot
// being up or on any index being in sync.
//
// Blobs are stored by multiblob at blobs/<alg>/<hex[0:2]>/<hex[2:]>, where hex
// is the base64 blob hash decoded to hex (see multiblob/index.js toPath).
var fs = require('fs')
var path = require('path')
var DELETE = process.argv.indexOf('--delete') !== -1
var HOME = process.env.HOME || '/home/cyberian'
var LOG_OFFSET = path.join(HOME, '.ssb/flume/log.offset')
var BLOB_DIR = path.join(HOME, '.ssb/blobs/sha256')
var PUSH_DIR = path.join(HOME, '.ssb/blobs_push')
var MANIFEST = path.join(HOME, 'logs/pruned-blobs.txt')
var BLOB_RE = /&[A-Za-z0-9+/]{43}=\.sha256/g
// sha256 of the empty string; ssb-blobs special-cases it and never stores it.
var EMPTY_HASH = '&47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=.sha256'
function idToHex (id) {
return Buffer.from(id.slice(1).replace(/\.sha256$/, ''), 'base64').toString('hex')
}
function hexToId (hex) {
return '&' + Buffer.from(hex, 'hex').toString('base64') + '.sha256'
}
function human (bytes) {
var u = ['B', 'KB', 'MB', 'GB']
var i = 0
while (bytes >= 1024 && i < u.length - 1) { bytes /= 1024; i++ }
return bytes.toFixed(1) + ' ' + u[i]
}
// ---- keep set -------------------------------------------------------------
var keep = Object.create(null)
keep[EMPTY_HASH] = true
var log = fs.readFileSync(LOG_OFFSET).toString('binary')
var refs = log.match(BLOB_RE) || []
refs.forEach(function (r) { keep[r] = true })
var fromLog = Object.keys(keep).length - 1
// Anything queued for push to the network is ours by definition. The leveldb
// is locked by the running sbot, so scrape the ids out of its files instead of
// opening it.
var fromPush = 0
try {
fs.readdirSync(PUSH_DIR).forEach(function (f) {
if (!/\.(log|ldb)$/.test(f)) return
var buf = fs.readFileSync(path.join(PUSH_DIR, f)).toString('binary')
;(buf.match(BLOB_RE) || []).forEach(function (r) {
if (!keep[r]) { keep[r] = true; fromPush++ }
})
})
} catch (e) {
console.error('warning: could not scan blobs_push:', e.message)
}
// ---- walk the blob store --------------------------------------------------
var kept = { n: 0, bytes: 0 }
var doomed = []
var orphans = []
fs.readdirSync(BLOB_DIR).forEach(function (sub) {
var subdir = path.join(BLOB_DIR, sub)
var st
try { st = fs.statSync(subdir) } catch (e) { return }
if (!st.isDirectory()) return
fs.readdirSync(subdir).forEach(function (name) {
var full = path.join(subdir, name)
var size
try { size = fs.statSync(full).size } catch (e) { return }
var id
try {
id = hexToId(sub + name)
} catch (e) {
orphans.push(full)
return
}
if (keep[id]) { kept.n++; kept.bytes += size }
else doomed.push({ id: id, path: full, size: size })
})
})
var doomedBytes = doomed.reduce(function (a, b) { return a + b.size }, 0)
console.log('keep set: ' + (fromLog + fromPush) +
' (' + fromLog + ' referenced in the log, ' + fromPush + ' from blobs_push)')
console.log('blobs on disk: ' + (kept.n + doomed.length))
console.log(' keeping: ' + kept.n + ' (' + human(kept.bytes) + ')')
console.log(' removing: ' + doomed.length + ' (' + human(doomedBytes) + ')')
if (orphans.length) console.log(' unparseable: ' + orphans.length + ' (left alone)')
// A keep-set entry we do not actually have on disk is fine -- blob-wanter has a
// standing want for it. Just report so the numbers reconcile.
var missing = Object.keys(keep).filter(function (id) {
if (id === EMPTY_HASH) return false
var hex = idToHex(id)
return !fs.existsSync(path.join(BLOB_DIR, hex.substring(0, 2), hex.substring(2)))
})
if (missing.length) {
console.log(' note: ' + missing.length + ' referenced blob(s) are not on disk; ' +
'blob-wanter holds standing wants for them')
}
if (!DELETE) {
console.log('\nDRY RUN — nothing deleted. Re-run with --delete to apply.')
process.exit(0)
}
// ---- delete ---------------------------------------------------------------
// Manifest first, so a crash mid-delete still leaves a record of what went.
fs.mkdirSync(path.dirname(MANIFEST), { recursive: true })
var out = fs.createWriteStream(MANIFEST, { flags: 'a' })
out.write('# pruned ' + new Date().toISOString() + ' — ' + doomed.length +
' blobs, ' + human(doomedBytes) + '\n')
out.write('# re-request any of these with: ssb-server blobs.want <id>\n')
doomed.forEach(function (d) { out.write(d.id + '\t' + d.size + '\n') })
out.end()
var removed = 0, failed = 0, freed = 0
doomed.forEach(function (d) {
try { fs.unlinkSync(d.path); removed++; freed += d.size }
catch (e) { failed++ }
})
// Clean up the now-empty two-char shard directories.
fs.readdirSync(BLOB_DIR).forEach(function (sub) {
var subdir = path.join(BLOB_DIR, sub)
try {
if (fs.statSync(subdir).isDirectory() && fs.readdirSync(subdir).length === 0) {
fs.rmdirSync(subdir)
}
} catch (e) { /* not empty, or vanished */ }
})
console.log('\nremoved ' + removed + ' blobs, freed ' + human(freed) +
(failed ? ', ' + failed + ' failed' : ''))
console.log('manifest: ' + MANIFEST)