forked from snxraven/peardock
414 lines
12 KiB
JavaScript
414 lines
12 KiB
JavaScript
/**
|
|
* Peer connection roster cache.
|
|
*
|
|
* Primary path (desktop / Pear):
|
|
* ~/.config/peardock/cache/peers.json
|
|
*
|
|
* Falls back to localStorage when the filesystem is unavailable
|
|
* (pure browser / restricted environments).
|
|
*/
|
|
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import os from 'os'
|
|
|
|
export const PEERS_CACHE_VERSION = 1
|
|
export const LOCALSTORAGE_KEY = 'peardock_connections'
|
|
export const LOCALSTORAGE_FLAG = 'peardock_use_localstorage'
|
|
/** Last UI-selected active peer id (12-char prefix) */
|
|
export const ACTIVE_PEER_KEY = 'peardock_active_peer_id'
|
|
|
|
/**
|
|
* Absolute path to the peers cache file.
|
|
* @returns {string}
|
|
*/
|
|
export function getPeersCachePath() {
|
|
const home =
|
|
process.env.PEARDOCK_HOME ||
|
|
process.env.HOME ||
|
|
process.env.USERPROFILE ||
|
|
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
|
''
|
|
return path.join(home, '.config', 'peardock', 'cache', 'peers.json')
|
|
}
|
|
|
|
/**
|
|
* Directory containing peers.json
|
|
* @returns {string}
|
|
*/
|
|
export function getPeersCacheDir() {
|
|
return path.dirname(getPeersCachePath())
|
|
}
|
|
|
|
/**
|
|
* Normalize optional SERVER_SEED for admin reconnect (64 hex only).
|
|
* @param {unknown} raw
|
|
* @returns {string|null}
|
|
*/
|
|
export function normalizeAdminSeed(raw) {
|
|
if (raw == null || raw === '') return null
|
|
const seed = String(raw).trim().toLowerCase()
|
|
return /^[0-9a-f]{64}$/.test(seed) ? seed : null
|
|
}
|
|
|
|
/**
|
|
* Normalize one peer entry.
|
|
* @param {object|string} value
|
|
* @param {string} [idHint]
|
|
* @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null, adminSeed: string|null }|null}
|
|
*/
|
|
export function normalizePeerEntry(value, idHint = '') {
|
|
if (!value) return null
|
|
if (typeof value === 'string') {
|
|
const publicKeyHex = value.trim().toLowerCase()
|
|
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
|
|
return {
|
|
id: publicKeyHex.slice(0, 12),
|
|
publicKeyHex,
|
|
alias: null,
|
|
inviteToken: null,
|
|
capability: null,
|
|
adminSeed: null,
|
|
}
|
|
}
|
|
const publicKeyHex = String(
|
|
value.publicKeyHex || value.topicHex || value.topic || ''
|
|
)
|
|
.trim()
|
|
.toLowerCase()
|
|
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
|
|
const id = String(idHint || value.id || publicKeyHex.slice(0, 12)).slice(0, 12)
|
|
// Prefer capability; migrate inviteToken if it looks like an HMAC grant
|
|
let capability = value.capability || null
|
|
let inviteToken = value.inviteToken || null
|
|
if (!capability && inviteToken && String(inviteToken).includes('.')) {
|
|
capability = inviteToken
|
|
inviteToken = null
|
|
}
|
|
return {
|
|
id,
|
|
publicKeyHex,
|
|
alias: value.alias || null,
|
|
inviteToken,
|
|
capability,
|
|
// Persist for auto-reconnect as admin (file mode 0600)
|
|
adminSeed: normalizeAdminSeed(value.adminSeed),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse raw JSON (file or localStorage) into an id → peer map.
|
|
* Accepts:
|
|
* - { version, peers: { id: {...} } }
|
|
* - flat { id: {...} } (legacy)
|
|
* @param {string|object} raw
|
|
* @returns {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
|
|
*/
|
|
export function parsePeersPayload(raw) {
|
|
let parsed = raw
|
|
if (typeof raw === 'string') {
|
|
try {
|
|
parsed = JSON.parse(raw)
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
if (!parsed || typeof parsed !== 'object') return {}
|
|
|
|
const source =
|
|
parsed.peers && typeof parsed.peers === 'object' && !Array.isArray(parsed.peers)
|
|
? parsed.peers
|
|
: parsed
|
|
|
|
/** @type {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null, adminSeed: string|null }>} */
|
|
const out = {}
|
|
for (const [key, value] of Object.entries(source)) {
|
|
// Skip meta keys if someone stored a flat object with version
|
|
if (key === 'version' || key === 'updatedAt' || key === 'peers' || key === 'activePeerId') continue
|
|
const entry = normalizePeerEntry(value, key)
|
|
if (!entry) continue
|
|
out[entry.id] = {
|
|
publicKeyHex: entry.publicKeyHex,
|
|
alias: entry.alias,
|
|
inviteToken: entry.inviteToken,
|
|
capability: entry.capability,
|
|
adminSeed: entry.adminSeed,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Build on-disk payload.
|
|
* @param {Record<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null, capability?: string|null, adminSeed?: string|null }>} peersMap
|
|
* @param {{ activePeerId?: string|null }} [opts]
|
|
*/
|
|
export function buildPeersPayload(peersMap, opts = {}) {
|
|
const peers = {}
|
|
for (const [id, value] of Object.entries(peersMap || {})) {
|
|
const entry = normalizePeerEntry(value, id)
|
|
if (!entry) continue
|
|
const row = {
|
|
publicKeyHex: entry.publicKeyHex,
|
|
alias: entry.alias,
|
|
inviteToken: entry.inviteToken,
|
|
capability: entry.capability,
|
|
}
|
|
// Only write seed when present (omit null to avoid empty secret fields)
|
|
if (entry.adminSeed) row.adminSeed = entry.adminSeed
|
|
peers[entry.id] = row
|
|
}
|
|
const payload = {
|
|
version: PEERS_CACHE_VERSION,
|
|
updatedAt: new Date().toISOString(),
|
|
peers,
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(opts, 'activePeerId')) {
|
|
if (opts.activePeerId != null && opts.activePeerId !== '') {
|
|
payload.activePeerId = String(opts.activePeerId).slice(0, 12)
|
|
}
|
|
// null/'' → omit (clear)
|
|
} else {
|
|
// preserve existing disk active id when not provided
|
|
const existing = readActivePeerIdFromFile()
|
|
if (existing) payload.activePeerId = existing
|
|
}
|
|
return payload
|
|
}
|
|
|
|
/**
|
|
* @returns {string|null}
|
|
*/
|
|
function readActivePeerIdFromFile() {
|
|
const file = getPeersCachePath()
|
|
try {
|
|
if (!fs.existsSync(file)) return null
|
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
const id = raw?.activePeerId
|
|
return id ? String(id).slice(0, 12) : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Last known active peer id (file first, then localStorage).
|
|
* @returns {string|null}
|
|
*/
|
|
export function getLastActivePeerId() {
|
|
const fromFile = readActivePeerIdFromFile()
|
|
if (fromFile) return fromFile
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
const id = localStorage.getItem(ACTIVE_PEER_KEY)
|
|
return id ? String(id).slice(0, 12) : null
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Persist last active peer id (localStorage + peers.json meta when possible).
|
|
* @param {string|null} id
|
|
*/
|
|
export function setLastActivePeerId(id) {
|
|
const next = id ? String(id).slice(0, 12) : null
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
if (next) localStorage.setItem(ACTIVE_PEER_KEY, next)
|
|
else localStorage.removeItem(ACTIVE_PEER_KEY)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Merge into peers.json without rewriting peer entries
|
|
try {
|
|
const peers = loadPeers()
|
|
if (Object.keys(peers).length === 0 && !next) return
|
|
writePeersToFile(peers, { activePeerId: next })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function ensureCacheDir() {
|
|
const dir = getPeersCacheDir()
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
return dir
|
|
}
|
|
|
|
/**
|
|
* Read peers from ~/.config/peardock/cache/peers.json
|
|
* @returns {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
|
|
*/
|
|
export function readPeersFromFile() {
|
|
const file = getPeersCachePath()
|
|
try {
|
|
if (!fs.existsSync(file)) return {}
|
|
const raw = fs.readFileSync(file, 'utf8')
|
|
return parsePeersPayload(raw)
|
|
} catch (err) {
|
|
console.warn('[WARN] peerCache: failed to read file', err?.message || err)
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write peers to ~/.config/peardock/cache/peers.json (atomic replace).
|
|
* @param {Record<string, object>} peersMap
|
|
* @param {{ activePeerId?: string|null }} [opts]
|
|
* @returns {boolean}
|
|
*/
|
|
export function writePeersToFile(peersMap, opts = {}) {
|
|
const file = getPeersCachePath()
|
|
const payload = buildPeersPayload(peersMap, opts)
|
|
const json = JSON.stringify(payload, null, 2)
|
|
try {
|
|
ensureCacheDir()
|
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
fs.writeFileSync(tmp, json, { encoding: 'utf8', mode: 0o600 })
|
|
fs.renameSync(tmp, file)
|
|
try {
|
|
fs.chmodSync(file, 0o600)
|
|
} catch {
|
|
// ignore chmod failures on some platforms
|
|
}
|
|
return true
|
|
} catch (err) {
|
|
console.error('[ERROR] peerCache: failed to write file', err?.message || err)
|
|
// Best-effort cleanup of temp files is ignored
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the peers cache file (and leave empty dir).
|
|
* @returns {boolean}
|
|
*/
|
|
export function clearPeersFile() {
|
|
try {
|
|
const file = getPeersCachePath()
|
|
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
return true
|
|
} catch (err) {
|
|
console.warn('[WARN] peerCache: failed to clear file', err?.message || err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read peers: file first, then migrate from localStorage if file empty.
|
|
* @returns {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
|
|
*/
|
|
export function loadPeers() {
|
|
let peers = readPeersFromFile()
|
|
if (Object.keys(peers).length > 0) return peers
|
|
|
|
// Migrate legacy browser storage once
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
const raw = localStorage.getItem(LOCALSTORAGE_KEY)
|
|
if (raw) {
|
|
peers = parsePeersPayload(raw)
|
|
if (Object.keys(peers).length > 0) {
|
|
writePeersToFile(peers)
|
|
console.log(
|
|
'[INFO] peerCache: migrated',
|
|
Object.keys(peers).length,
|
|
'peer(s) from localStorage →',
|
|
getPeersCachePath()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return peers
|
|
}
|
|
|
|
/**
|
|
* Persist peers to file (+ mirror localStorage as secondary cache).
|
|
* @param {Record<string, object>} peersMap
|
|
* @param {{ activePeerId?: string|null }} [opts]
|
|
* @returns {{ ok: boolean, path: string, count: number }}
|
|
*/
|
|
export function savePeers(peersMap, opts = {}) {
|
|
const payload = buildPeersPayload(peersMap, opts)
|
|
// Pass through only when caller set activePeerId; otherwise preserve on disk
|
|
const writeOpts = Object.prototype.hasOwnProperty.call(opts, 'activePeerId')
|
|
? { activePeerId: opts.activePeerId }
|
|
: {}
|
|
const ok = writePeersToFile(payload.peers, writeOpts)
|
|
|
|
// Mirror for quick in-session reads / legacy code paths
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
// Keep flat map for older readers
|
|
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(payload.peers))
|
|
localStorage.setItem(LOCALSTORAGE_FLAG, '1')
|
|
if (payload.activePeerId) {
|
|
localStorage.setItem(ACTIVE_PEER_KEY, payload.activePeerId)
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return {
|
|
ok,
|
|
path: getPeersCachePath(),
|
|
count: Object.keys(payload.peers).length,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear file + localStorage peer roster.
|
|
*/
|
|
export function clearPeers() {
|
|
clearPeersFile()
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.removeItem(LOCALSTORAGE_KEY)
|
|
localStorage.removeItem(LOCALSTORAGE_FLAG)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List form used by ConnectionManager.loadSaved()
|
|
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null, adminSeed: string|null }>}
|
|
*/
|
|
export function listSavedPeers() {
|
|
const map = loadPeers()
|
|
return Object.entries(map).map(([id, value]) => ({
|
|
id,
|
|
publicKeyHex: value.publicKeyHex,
|
|
alias: value.alias || null,
|
|
inviteToken: value.inviteToken || null,
|
|
capability: value.capability || null,
|
|
adminSeed: value.adminSeed || null,
|
|
}))
|
|
}
|
|
|
|
export default {
|
|
getPeersCachePath,
|
|
getPeersCacheDir,
|
|
loadPeers,
|
|
savePeers,
|
|
clearPeers,
|
|
listSavedPeers,
|
|
parsePeersPayload,
|
|
buildPeersPayload,
|
|
normalizePeerEntry,
|
|
normalizeAdminSeed,
|
|
readPeersFromFile,
|
|
writePeersToFile,
|
|
getLastActivePeerId,
|
|
setLastActivePeerId,
|
|
}
|