All files / src/cron backups.js

100% Statements 73/73
100% Branches 16/16
100% Functions 6/6
100% Lines 71/71

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229                    7x 7x 7x     7x                                                               7x     7x     7x                           7x             171x 166x 166x               55x 55x   55x 66x           65x 87x               54x 54x               17x 17x 17x     17x 17x 171x 171x 166x 166x   166x   166x         166x 166x           5x         17x 55x 55x 54x 54x   54x         54x 54x           1x               17x 17x 17x 17x     17x 17x 17x       17x           17x   17x   17x                 34x 34x   34x   7x 5x 5x   2x     7x 7x     34x 3x 3x       7x 7x 7x 7x 7x 7x  
/**
 * Cron: Full database backup to R2.
 *
 * Backs up all Firestore collections (top-level + subcollections) to R2
 * under `backups/full/YYYY-MM-DD/`. Also writes a backwards-compatible
 * `backups/users/YYYY-MM-DD.json` file.
 *
 * Prunes backups older than 7 days.
 */
 
const { db } = require('../utils/firebase');
const r2 = require('../utils/r2');
const log = require('../utils/log');
 
// Top-level collections to back up (must match actual Firestore collections)
const ALL_TOP_LEVEL_COLLECTIONS = [
  'users',
  'rooms',
  'conversations',
  'config',
  'identityMap',
  'counters',
  'deviceBindings',
  'gifts',
  'giftRankings',
  'broadcasts',
  'coinPackages',
  'funFacts',
  'banners',
  'reports',
  'reportsArchive',
  'reportLocks',
  'suspensionAppeals',
  'alerts',
  'alertConfig',
  'adminAuditLog',
  'otpCodes',
  'biometricKeys',
  'emailMetrics',
  'purchaseReceipts',
  'logConfig',
  'deviceBans',
  'networkBans',
];
 
// Dev only needs essential data — skip the other 24 collections and all subcollections
// to save 1,000-3,000 Firestore reads/day
const DEV_TOP_LEVEL_COLLECTIONS = ['users', 'config', 'counters'];
 
const TOP_LEVEL_COLLECTIONS =
  process.env.NODE_ENV === 'production' ? ALL_TOP_LEVEL_COLLECTIONS : DEV_TOP_LEVEL_COLLECTIONS;
 
// Subcollections: [parentCollection, subcollectionName]
const ALL_SUBCOLLECTIONS = [
  ['rooms', 'messages'],
  ['rooms', 'seatRequests'],
  ['conversations', 'messages'],
  ['conversations', 'userSettings'],
  ['conversations', 'mutes'],
  ['users', 'backpack'],
  ['users', 'warnings'],
  ['users', 'giftWall'],
  ['users', 'transactions'],
  ['users', 'stalkers'],
  ['conversations', 'settings'],
];
 
const SUBCOLLECTIONS = process.env.NODE_ENV === 'production' ? ALL_SUBCOLLECTIONS : [];
 
/**
 * Back up a single top-level collection.
 * Returns { name, docs, count }.
 */
async function backupCollection(name) {
  const snapshot = await db.collection(name).get();
  const docs = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }));
  return { name, docs, count: docs.length };
}
 
/**
 * Back up a subcollection across all parent docs.
 * Returns { name, docs, count } where name = `parent_sub`.
 */
async function backupSubcollection(parentCollection, subName) {
  const parentSnapshot = await db.collection(parentCollection).get();
  const allDocs = [];
 
  for (const parentDoc of parentSnapshot.docs) {
    const subSnapshot = await db
      .collection(parentCollection)
      .doc(parentDoc.id)
      .collection(subName)
      .get();
 
    for (const subDoc of subSnapshot.docs) {
      allDocs.push({
        id: subDoc.id,
        parentId: parentDoc.id,
        ...subDoc.data(),
      });
    }
  }
 
  const name = `${parentCollection}_${subName}`;
  return { name, docs: allDocs, count: allDocs.length };
}
 
/**
 * Run a full database backup.
 * Returns { date, manifest } on success.
 */
async function backups() {
  const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
  const prefix = `backups/full/${today}`;
  const manifest = { date: today, timestamp: new Date().toISOString(), collections: {} };
 
  // Back up top-level collections (retain users JSON for backwards-compat copy)
  let usersJsonStr = null;
  for (const collName of TOP_LEVEL_COLLECTIONS) {
    try {
      const { name, docs, count } = await backupCollection(collName);
      const key = `${prefix}/${name}.json`;
      const jsonStr = JSON.stringify(docs, null, 2);
 
      if (name === 'users') usersJsonStr = jsonStr;
 
      await r2.putObject(key, Buffer.from(jsonStr), 'application/json', {
        docCount: String(count),
        createdAt: new Date().toISOString(),
      });
 
      manifest.collections[name] = count;
      log.info('cron', 'backup: collection saved', {
        collection: name,
        docs: count,
        bytes: jsonStr.length,
      });
    } catch (err) {
      log.error('cron', 'backup: collection failed', { collection: collName, error: err.message });
    }
  }
 
  // Back up subcollections
  for (const [parent, sub] of SUBCOLLECTIONS) {
    try {
      const { name, docs, count } = await backupSubcollection(parent, sub);
      const key = `${prefix}/${name}.json`;
      const jsonStr = JSON.stringify(docs, null, 2);
 
      await r2.putObject(key, Buffer.from(jsonStr), 'application/json', {
        docCount: String(count),
        createdAt: new Date().toISOString(),
      });
 
      manifest.collections[name] = count;
      log.info('cron', 'backup: subcollection saved', {
        collection: name,
        docs: count,
        bytes: jsonStr.length,
      });
    } catch (err) {
      log.error('cron', 'backup: subcollection failed', {
        collection: `${parent}_${sub}`,
        error: err.message,
      });
    }
  }
 
  // Write manifest
  const manifestKey = `${prefix}/manifest.json`;
  const manifestStr = JSON.stringify(manifest, null, 2);
  await r2.putObject(manifestKey, Buffer.from(manifestStr), 'application/json');
  log.info('cron', 'backup: manifest written', { key: manifestKey });
 
  // Backwards compatibility: reuse the in-memory users JSON (no extra Firestore read)
  const usersKey = `backups/users/${today}.json`;
  const usersCount = manifest.collections['users'] || 0;
  await r2.putObject(usersKey, Buffer.from(usersJsonStr || '[]'), 'application/json', {
    userCount: String(usersCount),
    createdAt: new Date().toISOString(),
  });
  log.info('cron', 'backup: backwards-compat users backup saved', {
    key: usersKey,
    users: usersCount,
  });
 
  // Prune full backups older than 7 days
  await pruneOldBackups('backups/full/');
  // Also prune legacy users backups
  await pruneOldBackups('backups/users/');
 
  return { date: today, manifest };
}
 
/**
 * Prune backups older than 7 days under the given prefix.
 * For `backups/full/` prefix, date is extracted from the folder name.
 * For `backups/users/` prefix, date is extracted from the filename.
 */
async function pruneOldBackups(prefix) {
  const allKeys = await r2.listObjects(prefix);
  const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
 
  const toDelete = allKeys.filter((objKey) => {
    let dateStr;
    if (prefix === 'backups/full/') {
      const parts = objKey.replace(prefix, '').split('/');
      dateStr = parts[0];
    } else {
      dateStr = objKey.replace(prefix, '').replace('.json', '');
    }
 
    const backupDate = new Date(dateStr + 'T00:00:00Z');
    return !Number.isNaN(backupDate.getTime()) && backupDate.getTime() < sevenDaysAgo;
  });
 
  if (toDelete.length > 0) {
    await r2.deleteObjects(toDelete);
    log.info('cron', 'backup: pruned old backups', { prefix, count: toDelete.length });
  }
}
 
module.exports = backups;
module.exports.TOP_LEVEL_COLLECTIONS = TOP_LEVEL_COLLECTIONS;
module.exports.ALL_TOP_LEVEL_COLLECTIONS = ALL_TOP_LEVEL_COLLECTIONS;
module.exports.DEV_TOP_LEVEL_COLLECTIONS = DEV_TOP_LEVEL_COLLECTIONS;
module.exports.SUBCOLLECTIONS = SUBCOLLECTIONS;
module.exports.ALL_SUBCOLLECTIONS = ALL_SUBCOLLECTIONS;