Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
161 lines
6 KiB
JavaScript
161 lines
6 KiB
JavaScript
const fs = require('fs');
|
|
const pull = require('../server/node_modules/pull-stream');
|
|
|
|
const BLOB_RE = /&[A-Za-z0-9+/=]{44}\.sha256/g;
|
|
const DEFAULT_MAX_MB = 0;
|
|
const PROTECT_MS = 24 * 60 * 60 * 1000;
|
|
const FLUSH_MS = 60 * 1000;
|
|
|
|
const pinsPath = () => {
|
|
try { return require('../configs/state-manager').statePath('blob-pins.json'); } catch (_) { return null; }
|
|
};
|
|
|
|
const accessPath = () => {
|
|
try { return require('../configs/state-manager').statePath('blob-access.json'); } catch (_) { return null; }
|
|
};
|
|
|
|
module.exports = ({ cooler } = {}) => {
|
|
const openSsb = async () => cooler.open();
|
|
let access = null;
|
|
let dirty = false;
|
|
let flushTimer = null;
|
|
|
|
const loadAccess = () => {
|
|
if (access) return access;
|
|
access = {};
|
|
const p = accessPath();
|
|
if (p) { try { access = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; } catch (_) { access = {}; } }
|
|
return access;
|
|
};
|
|
|
|
const flush = () => {
|
|
if (!dirty) return;
|
|
const p = accessPath();
|
|
if (!p) return;
|
|
try { fs.writeFileSync(p, JSON.stringify(loadAccess())); dirty = false; } catch (_) {}
|
|
};
|
|
|
|
const normalizeId = (raw) => {
|
|
let id = String(raw || '').trim();
|
|
try { id = decodeURIComponent(id); } catch (_) {}
|
|
if (!id) return null;
|
|
if (!id.startsWith('&')) id = `&${id}`;
|
|
return /^&[A-Za-z0-9+/=]{44}\.sha256$/.test(id) ? id : null;
|
|
};
|
|
|
|
const touch = (raw) => {
|
|
const id = normalizeId(raw);
|
|
if (!id) return;
|
|
loadAccess()[id] = Date.now();
|
|
dirty = true;
|
|
if (!flushTimer) {
|
|
flushTimer = setTimeout(() => { flushTimer = null; flush(); }, FLUSH_MS);
|
|
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
}
|
|
};
|
|
|
|
const listBlobs = async (ssb) => new Promise((resolve) => {
|
|
if (!ssb.blobs || typeof ssb.blobs.ls !== 'function') return resolve([]);
|
|
pull(ssb.blobs.ls({ long: true }), pull.collect((err, arr) => {
|
|
if (err) return resolve([]);
|
|
resolve((arr || []).filter(b => b && b.id).map(b => ({ id: b.id, size: Number(b.size) || 0, ts: Number(b.ts) || 0 })));
|
|
}));
|
|
});
|
|
|
|
const loadPins = () => {
|
|
const p = pinsPath();
|
|
if (!p) return [];
|
|
try { const arr = JSON.parse(fs.readFileSync(p, 'utf8')); return Array.isArray(arr) ? arr : []; } catch (_) { return []; }
|
|
};
|
|
|
|
const pin = (ids) => {
|
|
const p = pinsPath();
|
|
if (!p) return;
|
|
const set = new Set(loadPins());
|
|
for (const raw of (Array.isArray(ids) ? ids : [ids])) { const id = normalizeId(raw); if (id) set.add(id); }
|
|
try { fs.writeFileSync(p, JSON.stringify(Array.from(set))); } catch (_) {}
|
|
};
|
|
|
|
const getContent = (ssb, key) => new Promise((resolve) => {
|
|
try { ssb.get(key, (err, v) => resolve(!err && v && v.content && typeof v.content === 'object' ? v.content : null)); } catch (_) { resolve(null); }
|
|
});
|
|
|
|
const ownBlobIds = async (ssb) => {
|
|
const own = new Set();
|
|
const msgs = await new Promise((resolve) => {
|
|
pull(ssb.createUserStream({ id: ssb.id, reverse: false }), pull.collect((err, arr) => resolve(err ? [] : (arr || []))));
|
|
});
|
|
const spreads = new Map();
|
|
const tombstoned = new Set();
|
|
for (const m of msgs) {
|
|
const content = m && m.value && m.value.content;
|
|
if (!content || typeof content !== 'object') continue;
|
|
if (content.type === 'spread' && typeof content.link === 'string') spreads.set(m.key, content.link);
|
|
else if (content.type === 'tombstone' && typeof content.target === 'string') tombstoned.add(content.target);
|
|
for (const id of (JSON.stringify(content).match(BLOB_RE) || [])) own.add(id);
|
|
}
|
|
if (ssb.private && typeof ssb.private.read === 'function') {
|
|
const boxed = await new Promise((resolve) => {
|
|
pull(ssb.private.read({ reverse: false }), pull.collect((err, arr) => resolve(err ? [] : (arr || []))));
|
|
});
|
|
for (const m of boxed) {
|
|
const content = m && m.value && m.value.content;
|
|
if (content && typeof content === 'object') for (const id of (JSON.stringify(content).match(BLOB_RE) || [])) own.add(id);
|
|
}
|
|
}
|
|
for (const id of loadPins()) own.add(id);
|
|
for (const [key, link] of spreads) {
|
|
if (tombstoned.has(key)) continue;
|
|
const seeded = await getContent(ssb, link);
|
|
if (!seeded || (seeded.type !== 'torrent' && seeded.type !== 'file')) continue;
|
|
for (const id of (JSON.stringify(seeded).match(BLOB_RE) || [])) own.add(id);
|
|
}
|
|
return own;
|
|
};
|
|
|
|
const usage = async () => {
|
|
const ssb = await openSsb();
|
|
const blobs = await listBlobs(ssb);
|
|
return { count: blobs.length, bytes: blobs.reduce((n, b) => n + b.size, 0) };
|
|
};
|
|
|
|
const collect = async ({ maxBytes, now = Date.now(), protectMs = PROTECT_MS } = {}) => {
|
|
const limit = Number(maxBytes);
|
|
const ssb = await openSsb();
|
|
const blobs = await listBlobs(ssb);
|
|
const total = blobs.reduce((n, b) => n + b.size, 0);
|
|
const result = { before: total, after: total, deleted: 0, freed: 0, kept: blobs.length };
|
|
if (!(limit > 0) || total <= limit) return result;
|
|
const own = await ownBlobIds(ssb);
|
|
const seen = loadAccess();
|
|
const candidates = blobs
|
|
.filter(b => !own.has(b.id))
|
|
.map(b => ({ ...b, last: Math.max(Number(seen[b.id]) || 0, b.ts) }))
|
|
.filter(b => now - b.last > protectMs)
|
|
.sort((a, b) => a.last - b.last);
|
|
let current = total;
|
|
for (const b of candidates) {
|
|
if (current <= limit) break;
|
|
const removed = await new Promise((resolve) => ssb.blobs.rm(b.id, (err) => resolve(!err)));
|
|
if (!removed) continue;
|
|
current -= b.size;
|
|
result.deleted += 1;
|
|
result.freed += b.size;
|
|
delete seen[b.id];
|
|
dirty = true;
|
|
}
|
|
result.after = current;
|
|
result.kept = blobs.length - result.deleted;
|
|
flush();
|
|
return result;
|
|
};
|
|
|
|
const maxBytesFor = (cfg, { isPublic = false } = {}) => {
|
|
const mb = Number(cfg && cfg.blobCache && (isPublic ? cfg.blobCache.pubMaxMB : cfg.blobCache.maxMB));
|
|
if (!Number.isFinite(mb) || mb <= 0) return 0;
|
|
return Math.round(mb * 1024 * 1024);
|
|
};
|
|
|
|
return { touch, usage, collect, ownBlobIds, maxBytesFor, flush, pin, DEFAULT_MAX_MB };
|
|
};
|
|
module.exports.DEFAULT_MAX_MB = DEFAULT_MAX_MB;
|