← back to Butlr

mac2-contacts-service/server.js

187 lines

// Mac2-resident contacts service. Reads macOS Contacts via the SQLite DBs
// at ~/Library/Application Support/AddressBook/Sources/*/AddressBook-v22.abcddb
// — bypasses macOS TCC privacy prompts (which gate the AppleScript / Contacts
// API). Direct file reads work because the privacy gate is on the API, not
// the file itself.
//
// Listens on Tailscale + localhost so Butlr prod (Kamatera) can search
// contacts without bulk-exporting anything. Per /search request returns
// only matching name+phone, max 25 rows. Every read is audited.
//
// Endpoints:
//   GET /health  → { ok, count, sources }
//   GET /search?q=<query>  → { ok, contacts: [{ name, phones:[E.164] }] }
//
// Auth: X-Butlr-Token header == CONTACTS_SHARED_TOKEN.

const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');

const PORT = parseInt(process.env.CONTACTS_PORT || '9937', 10);
const BIND = process.env.CONTACTS_BIND || '0.0.0.0';
const SHARED_TOKEN = process.env.CONTACTS_SHARED_TOKEN || 'CHANGE_ME_BEFORE_USE';
const LOG_PATH = path.join(os.homedir(), 'Library/Logs/butlr-contacts-access.log');

const AB_SOURCES_DIR = path.join(os.homedir(), 'Library/Application Support/AddressBook/Sources');
const OVERLAY_PATH = path.join(os.homedir(), '.butlr-contacts.json');

function audit(...args) {
  const line = `[${new Date().toISOString()}] ${args.join(' ')}\n`;
  try { fs.appendFileSync(LOG_PATH, line); } catch {}
  process.stdout.write(line);
}

function findSourceDbs() {
  try {
    return fs.readdirSync(AB_SOURCES_DIR)
      .map(s => path.join(AB_SOURCES_DIR, s, 'AddressBook-v22.abcddb'))
      .filter(p => fs.existsSync(p));
  } catch { return []; }
}

function normalize(rawPhone) {
  const digits = String(rawPhone || '').replace(/\D/g, '');
  if (!digits) return null;
  if (digits.length === 10) return '+1' + digits;
  if (digits.length === 11 && digits[0] === '1') return '+' + digits;
  if (digits.length >= 8 && digits.length <= 15) return '+' + digits;
  return null;
}

const SOURCES = findSourceDbs();
audit(`found ${SOURCES.length} contacts DBs`);

let CACHE = null;
let CACHE_AT = 0;
const CACHE_TTL = 5 * 60 * 1000;

function loadAll() {
  const t0 = Date.now();
  const all = new Map(); // dedupe key = name|firstPhone
  for (const db of SOURCES) {
    try {
      const sql = `SELECT IFNULL(r.ZFIRSTNAME,'') AS fn, IFNULL(r.ZLASTNAME,'') AS ln, IFNULL(r.ZORGANIZATION,'') AS org, p.ZFULLNUMBER
                   FROM ZABCDRECORD r JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK
                   WHERE p.ZFULLNUMBER IS NOT NULL`;
      const out = execFileSync('/usr/bin/sqlite3', ['-separator', '\t', db, sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
      for (const line of out.split('\n')) {
        if (!line.trim()) continue;
        const [fn, ln, org, phone] = line.split('\t');
        const name = ([fn, ln].filter(Boolean).join(' ').trim()) || org || '';
        if (!name) continue;
        const norm = normalize(phone);
        if (!norm) continue;
        const key = name.toLowerCase();
        if (!all.has(key)) all.set(key, { name, phones: new Set() });
        all.get(key).phones.add(norm);
      }
    } catch (e) {
      audit(`source read ERROR ${path.basename(path.dirname(db))}: ${e.message}`);
    }
  }
  // Merge in Butlr-overlay file (~/.butlr-contacts.json) — contacts you add
  // via the Butlr admin UI that aren't in macOS Contacts. Overlay entries
  // shadow same-name entries from SQLite, so you can correct / extend.
  let overlayCount = 0;
  try {
    if (fs.existsSync(OVERLAY_PATH)) {
      const overlay = JSON.parse(fs.readFileSync(OVERLAY_PATH, 'utf8'));
      if (Array.isArray(overlay)) {
        for (const o of overlay) {
          if (!o || !o.name) continue;
          const key = String(o.name).toLowerCase();
          const phones = (Array.isArray(o.phones) ? o.phones : [o.phone]).map(normalize).filter(Boolean);
          if (!phones.length) continue;
          const existing = all.get(key);
          if (existing) {
            for (const p of phones) existing.phones.add(p);
          } else {
            all.set(key, { name: String(o.name), phones: new Set(phones) });
          }
          overlayCount++;
        }
      }
    }
  } catch (e) {
    audit(`overlay read ERROR: ${e.message}`);
  }
  const list = Array.from(all.values()).map(c => ({ name: c.name, phones: Array.from(c.phones) }));
  audit(`loaded ${list.length} contacts in ${Date.now() - t0}ms (${SOURCES.length} SQLite sources + ${overlayCount} overlay entries)`);
  CACHE = list;
  CACHE_AT = Date.now();
  return list;
}

// Add a contact to the overlay file. Idempotent — merges by lowercase name.
function addOverlayContact(name, phone) {
  const norm = normalize(phone);
  if (!name || !norm) return { ok: false, error: 'name + valid phone required' };
  let overlay = [];
  try { if (fs.existsSync(OVERLAY_PATH)) overlay = JSON.parse(fs.readFileSync(OVERLAY_PATH, 'utf8')) || []; } catch {}
  const idx = overlay.findIndex(o => String(o.name).toLowerCase() === name.toLowerCase());
  if (idx >= 0) {
    if (!Array.isArray(overlay[idx].phones)) overlay[idx].phones = overlay[idx].phone ? [overlay[idx].phone] : [];
    if (!overlay[idx].phones.includes(norm)) overlay[idx].phones.push(norm);
  } else {
    overlay.push({ name: String(name), phones: [norm] });
  }
  fs.writeFileSync(OVERLAY_PATH, JSON.stringify(overlay, null, 2));
  // Invalidate cache so next read picks up the new entry
  CACHE = null;
  CACHE_AT = 0;
  audit(`overlay ADD name="${name}" phone="${norm}"`);
  return { ok: true, name, phone: norm };
}

function getContacts() {
  if (!CACHE || (Date.now() - CACHE_AT) > CACHE_TTL) return loadAll();
  return CACHE;
}

const app = express();

app.use((req, res, next) => {
  const token = req.headers['x-butlr-token'] || req.query.t;
  if (token !== SHARED_TOKEN) {
    audit(`AUTH FAIL ${req.ip} ${req.method} ${req.url}`);
    return res.status(403).json({ ok: false, error: 'bad_token' });
  }
  next();
});

app.get('/health', (req, res) => {
  try {
    const c = getContacts();
    res.json({ ok: true, count: c.length, sources: SOURCES.length, cached_age_ms: Date.now() - CACHE_AT });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

app.get('/search', (req, res) => {
  const q = String(req.query.q || '').trim().toLowerCase();
  if (!q || q.length < 2) return res.json({ ok: true, contacts: [] });
  try {
    const all = getContacts();
    const matches = all.filter(c => (c.name || '').toLowerCase().includes(q)).slice(0, 25);
    audit(`search q="${q}" returned ${matches.length} of ${all.length}`);
    res.json({ ok: true, contacts: matches });
  } catch (e) {
    audit(`search ERROR q="${q}": ${e.message}`);
    res.status(500).json({ ok: false, error: e.message });
  }
});

app.post('/add', express.json(), (req, res) => {
  const r = addOverlayContact(req.body && req.body.name, req.body && req.body.phone);
  if (!r.ok) return res.status(400).json(r);
  res.json(r);
});

app.listen(PORT, BIND, () => {
  audit(`Butlr contacts service (SQLite-direct) listening on ${BIND}:${PORT}`);
});