experiments in a post-browser web
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};