experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 373 lines 12 kB view raw
1/** 2 * Firefox profile reader 3 * 4 * Reads history and bookmarks from Firefox's places.sqlite, 5 * sessions from sessionstore-backups/recovery.jsonlz4, 6 * and inspects other data files (passwords, cookies, form data, extensions). 7 */ 8 9import { existsSync, readFileSync, copyFileSync, mkdtempSync } from 'fs'; 10import { join } from 'path'; 11import { tmpdir } from 'os'; 12import Database from 'better-sqlite3'; 13import type { BrowserReader } from './types.js'; 14import type { HistoryEntry, BookmarkEntry, SessionTab, DataInspection, VisitRecord } from '../types.js'; 15import { decompressJsonlz4 } from '../util/jsonlz4.js'; 16 17/** 18 * Copy a SQLite DB to a temp location before reading. 19 * Browsers hold locks on their DBs; reading in-place can fail or corrupt data. 20 */ 21function copyToTemp(dbPath: string): string { 22 const tempDir = mkdtempSync(join(tmpdir(), 'browser-import-')); 23 const tempPath = join(tempDir, 'copy.sqlite'); 24 copyFileSync(dbPath, tempPath); 25 // Also copy WAL and SHM files if they exist (needed for consistent reads) 26 const walPath = dbPath + '-wal'; 27 const shmPath = dbPath + '-shm'; 28 if (existsSync(walPath)) { 29 copyFileSync(walPath, tempPath + '-wal'); 30 } 31 if (existsSync(shmPath)) { 32 copyFileSync(shmPath, tempPath + '-shm'); 33 } 34 return tempPath; 35} 36 37function openSafely(dbPath: string): Database.Database | null { 38 if (!existsSync(dbPath)) return null; 39 try { 40 const tempPath = copyToTemp(dbPath); 41 return new Database(tempPath, { readonly: true }); 42 } catch { 43 return null; 44 } 45} 46 47/** 48 * Find the Firefox session file. Tries multiple locations in priority order: 49 * 1. sessionstore-backups/recovery.jsonlz4 (most current, written every ~15s) 50 * 2. sessionstore.jsonlz4 (written on clean shutdown) 51 */ 52function findSessionFile(profilePath: string): string | null { 53 const candidates = [ 54 join(profilePath, 'sessionstore-backups', 'recovery.jsonlz4'), 55 join(profilePath, 'sessionstore.jsonlz4'), 56 ]; 57 58 for (const candidate of candidates) { 59 if (existsSync(candidate)) return candidate; 60 } 61 return null; 62} 63 64/** 65 * Parse a Firefox session JSON structure into SessionTab entries. 66 * Session structure: { windows: [{ tabs: [{ entries: [{ url, title }], index, ... }] }] } 67 */ 68function parseFirefoxSession(sessionData: any): SessionTab[] { 69 const tabs: SessionTab[] = []; 70 71 if (!sessionData || !Array.isArray(sessionData.windows)) return tabs; 72 73 for (let windowIndex = 0; windowIndex < sessionData.windows.length; windowIndex++) { 74 const window = sessionData.windows[windowIndex]; 75 if (!window || !Array.isArray(window.tabs)) continue; 76 77 for (const tab of window.tabs) { 78 if (!tab || !Array.isArray(tab.entries) || tab.entries.length === 0) continue; 79 80 // Current entry is at index - 1 (1-based) 81 const currentIndex = (tab.index || tab.entries.length) - 1; 82 const entry = tab.entries[Math.min(currentIndex, tab.entries.length - 1)]; 83 84 if (!entry || !entry.url) continue; 85 86 // Skip internal Firefox URLs 87 if (entry.url.startsWith('about:') || entry.url.startsWith('resource:') || 88 entry.url.startsWith('chrome:') || entry.url.startsWith('moz-extension:')) { 89 continue; 90 } 91 92 // Determine tab group name (Firefox 131+ tab groups) 93 let tabGroup: string | undefined; 94 if (tab.groupId !== undefined && tab.groupId !== -1 && window.tabGroups) { 95 const group = window.tabGroups.find?.((g: any) => g.id === tab.groupId); 96 if (group && group.name) { 97 tabGroup = group.name; 98 } 99 } 100 101 tabs.push({ 102 url: entry.url, 103 title: entry.title || '', 104 tabGroup, 105 pinned: !!tab.pinned, 106 lastAccessed: tab.lastAccessed || undefined, 107 windowIndex, 108 }); 109 } 110 } 111 112 return tabs; 113} 114 115/** 116 * Count tabs in a Firefox session file without fully parsing 117 */ 118function countSessionTabs(profilePath: string): number { 119 const sessionFile = findSessionFile(profilePath); 120 if (!sessionFile) return 0; 121 122 try { 123 const sessionData = decompressJsonlz4(sessionFile); 124 const tabs = parseFirefoxSession(sessionData); 125 return tabs.length; 126 } catch { 127 return 0; 128 } 129} 130 131export const firefoxReader: BrowserReader = { 132 readHistory(profilePath: string): HistoryEntry[] { 133 const dbPath = join(profilePath, 'places.sqlite'); 134 const db = openSafely(dbPath); 135 if (!db) return []; 136 137 try { 138 // Get history entries with aggregated visit data 139 const rows = db.prepare(` 140 SELECT 141 p.url, 142 p.title, 143 p.visit_count, 144 MAX(v.visit_date) as last_visit_date 145 FROM moz_places p 146 JOIN moz_historyvisits v ON p.id = v.place_id 147 WHERE p.url NOT LIKE 'place:%' 148 AND p.url NOT LIKE 'about:%' 149 GROUP BY p.id 150 ORDER BY last_visit_date DESC 151 `).all() as Array<{ 152 url: string; 153 title: string | null; 154 visit_count: number; 155 last_visit_date: number; 156 }>; 157 158 // Build a map of place URL -> individual visits for visit timeline 159 const visitsByUrl = new Map<string, VisitRecord[]>(); 160 try { 161 const visitRows = db.prepare(` 162 SELECT 163 p.url, 164 v.visit_date, 165 v.visit_type 166 FROM moz_historyvisits v 167 JOIN moz_places p ON v.place_id = p.id 168 WHERE p.url NOT LIKE 'place:%' 169 AND p.url NOT LIKE 'about:%' 170 ORDER BY v.visit_date ASC 171 `).all() as Array<{ 172 url: string; 173 visit_date: number; 174 visit_type: number; 175 }>; 176 177 for (const vr of visitRows) { 178 let visits = visitsByUrl.get(vr.url); 179 if (!visits) { 180 visits = []; 181 visitsByUrl.set(vr.url, visits); 182 } 183 visits.push({ 184 timestamp: Math.floor(vr.visit_date / 1000), // microseconds to ms 185 visitType: vr.visit_type, 186 }); 187 } 188 } catch { 189 // visits query failed, continue without individual visits 190 } 191 192 return rows.map(row => ({ 193 url: row.url, 194 title: row.title || '', 195 visitCount: row.visit_count, 196 // Firefox stores timestamps in microseconds since epoch 197 lastVisitTime: Math.floor(row.last_visit_date / 1000), 198 visits: visitsByUrl.get(row.url), 199 })); 200 } catch { 201 return []; 202 } finally { 203 db.close(); 204 } 205 }, 206 207 readBookmarks(profilePath: string): BookmarkEntry[] { 208 const dbPath = join(profilePath, 'places.sqlite'); 209 const db = openSafely(dbPath); 210 if (!db) return []; 211 212 try { 213 // First build the folder tree for path resolution 214 const folders = db.prepare(` 215 SELECT id, title, parent 216 FROM moz_bookmarks 217 WHERE type = 2 218 `).all() as Array<{ id: number; title: string | null; parent: number }>; 219 220 const folderMap = new Map<number, { title: string; parent: number }>(); 221 for (const f of folders) { 222 folderMap.set(f.id, { title: f.title || '', parent: f.parent }); 223 } 224 225 function getFolderPath(parentId: number): string { 226 const parts: string[] = []; 227 let current = parentId; 228 const visited = new Set<number>(); 229 while (current && folderMap.has(current) && !visited.has(current)) { 230 visited.add(current); 231 const folder = folderMap.get(current)!; 232 if (folder.title) { 233 parts.unshift(folder.title); 234 } 235 current = folder.parent; 236 } 237 return parts.join('/'); 238 } 239 240 // Get bookmarks (type=1 is bookmark, not folder or separator) 241 const rows = db.prepare(` 242 SELECT 243 b.title as bookmark_title, 244 b.dateAdded, 245 b.parent, 246 p.url 247 FROM moz_bookmarks b 248 JOIN moz_places p ON b.fk = p.id 249 WHERE b.type = 1 250 AND p.url NOT LIKE 'place:%' 251 AND p.url NOT LIKE 'about:%' 252 `).all() as Array<{ 253 bookmark_title: string | null; 254 dateAdded: number; 255 parent: number; 256 url: string; 257 }>; 258 259 return rows.map(row => ({ 260 url: row.url, 261 title: row.bookmark_title || '', 262 folderPath: getFolderPath(row.parent), 263 // Firefox stores dateAdded in microseconds 264 dateAdded: Math.floor(row.dateAdded / 1000), 265 })); 266 } catch { 267 return []; 268 } finally { 269 db.close(); 270 } 271 }, 272 273 readSessions(profilePath: string): SessionTab[] { 274 const sessionFile = findSessionFile(profilePath); 275 if (!sessionFile) return []; 276 277 try { 278 const sessionData = decompressJsonlz4(sessionFile); 279 return parseFirefoxSession(sessionData); 280 } catch { 281 return []; 282 } 283 }, 284 285 inspect(profilePath: string): DataInspection[] { 286 const results: DataInspection[] = []; 287 288 // History 289 const placesDb = openSafely(join(profilePath, 'places.sqlite')); 290 if (placesDb) { 291 try { 292 const historyCount = (placesDb.prepare( 293 `SELECT COUNT(DISTINCT p.id) as cnt FROM moz_places p 294 JOIN moz_historyvisits v ON p.id = v.place_id 295 WHERE p.url NOT LIKE 'place:%' AND p.url NOT LIKE 'about:%'` 296 ).get() as { cnt: number }).cnt; 297 results.push({ type: 'history', count: historyCount, importable: true, label: 'History' }); 298 299 const bookmarkCount = (placesDb.prepare( 300 `SELECT COUNT(*) as cnt FROM moz_bookmarks b 301 JOIN moz_places p ON b.fk = p.id 302 WHERE b.type = 1 AND p.url NOT LIKE 'place:%' AND p.url NOT LIKE 'about:%'` 303 ).get() as { cnt: number }).cnt; 304 results.push({ type: 'bookmarks', count: bookmarkCount, importable: true, label: 'Bookmarks' }); 305 } catch { 306 // tables may not exist 307 } finally { 308 placesDb.close(); 309 } 310 } 311 312 // Sessions 313 const sessionTabCount = countSessionTabs(profilePath); 314 results.push({ 315 type: 'sessions', 316 count: sessionTabCount, 317 importable: true, 318 label: 'Session Tabs', 319 }); 320 321 // Passwords (logins.json) 322 const loginsPath = join(profilePath, 'logins.json'); 323 if (existsSync(loginsPath)) { 324 try { 325 const logins = JSON.parse(readFileSync(loginsPath, 'utf-8')); 326 const count = Array.isArray(logins.logins) ? logins.logins.length : 0; 327 results.push({ type: 'passwords', count, importable: false, label: 'Passwords' }); 328 } catch { 329 results.push({ type: 'passwords', count: 0, importable: false, label: 'Passwords' }); 330 } 331 } 332 333 // Cookies 334 const cookiesDb = openSafely(join(profilePath, 'cookies.sqlite')); 335 if (cookiesDb) { 336 try { 337 const count = (cookiesDb.prepare('SELECT COUNT(*) as cnt FROM moz_cookies').get() as { cnt: number }).cnt; 338 results.push({ type: 'cookies', count, importable: false, label: 'Cookies' }); 339 } catch { 340 // table may not exist 341 } finally { 342 cookiesDb.close(); 343 } 344 } 345 346 // Form data 347 const formDb = openSafely(join(profilePath, 'formhistory.sqlite')); 348 if (formDb) { 349 try { 350 const count = (formDb.prepare('SELECT COUNT(*) as cnt FROM moz_formhistory').get() as { cnt: number }).cnt; 351 results.push({ type: 'formdata', count, importable: false, label: 'Form Data' }); 352 } catch { 353 // table may not exist 354 } finally { 355 formDb.close(); 356 } 357 } 358 359 // Extensions 360 const extensionsPath = join(profilePath, 'extensions.json'); 361 if (existsSync(extensionsPath)) { 362 try { 363 const data = JSON.parse(readFileSync(extensionsPath, 'utf-8')); 364 const count = Array.isArray(data.addons) ? data.addons.filter((a: { type: string }) => a.type === 'extension').length : 0; 365 results.push({ type: 'extensions', count, importable: false, label: 'Extensions' }); 366 } catch { 367 results.push({ type: 'extensions', count: 0, importable: false, label: 'Extensions' }); 368 } 369 } 370 371 return results; 372 }, 373};