#!/usr/bin/env node /** Recover existing files through the user-confirmed Bubble attachment modal. */ import {chromium} from 'playwright'; import {readFileSync,mkdirSync,writeFileSync,lstatSync,openSync,writeSync,fsyncSync, closeSync,renameSync,unlinkSync} from 'node:fs'; import {resolve,dirname,join} from 'node:path'; import {homedir} from 'node:os'; import {fileURLToPath} from 'node:url'; import {createHash} from 'node:crypto'; import {parseArgs} from 'node:util'; import {assertSessionFresh,isCorpusResponse,boundedCaptureBody,ingestCapturedBody, normalizeBubbleRef} from './bubble-corpus-utils.mjs'; import {resourceParents,ingestResourceBody,attachmentRows} from './bubble-resource-utils.mjs'; const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..'),BASE='https://coulomb.social'; const MAX_FILE=64*1024*1024,MAX_TOTAL=512*1024*1024; const sha=raw=>createHash('sha256').update(raw).digest('hex'); const {values}=parseArgs({options:{articles:{type:'string'},output:{type:'string'}, 'expect-articles-sha256':{type:'string'},'example-article':{type:'string'}}}); function noLinks(path){for(let p=path;;p=dirname(p)){if(lstatSync(p).isSymbolicLink())throw Error('symlink');if(dirname(p)===p)break;}} function privateRead(path){noLinks(path);const s=lstatSync(path);if(!s.isFile()||s.uid!==process.getuid()||(s.mode&0o077)||s.nlink!==1||s.size>10000000)throw Error('private_input');return readFileSync(path);} let browser,total=0; try{ if(!values.articles||!values.output||!/^[a-f0-9]{64}$/.test(values['expect-articles-sha256']||''))throw Error('source_binding'); const source=resolve(values.articles),out=resolve(values.output),raw=privateRead(source); if(sha(raw)!==values['expect-articles-sha256'])throw Error('source_drift'); const example=values['example-article'];if(example&&!/^\d+x\d+$/.test(example))throw Error('example_id'); const parents=[...new Set([...(example?[example]:[]),...resourceParents(JSON.parse(raw))])]; if(!parents.length||parents.length>10)throw Error('target_bound'); if(out===ROOT||out.startsWith(ROOT+'/')||out.startsWith(dirname(source)+'/'))throw Error('output_overlap'); noLinks(dirname(out));const parent=lstatSync(dirname(out));if(parent.uid!==process.getuid()||(parent.mode&0o077))throw Error('private_parent'); const state=JSON.parse(privateRead(join(homedir(),'.config/coulomb-social/auth/storage-state.json')));assertSessionFresh(state); mkdirSync(out,{mode:0o700});mkdirSync(join(out,'files'),{mode:0o700}); const result={schema:'coulomb.bubble-attachment-modal-capture.v1',status:'capturing', started_at:new Date().toISOString(),source_articles_sha256:sha(raw),target_article_ids:parents, resources:{records:{},empty:{}},articles:{},attempts:[],files:{}, resource_completeness_verified:false,source_mutations:false,production_imported:false}; function checkpoint(){const f=join(out,'capture.json.partial'),fd=openSync(f,'w',0o600);try{writeFileSync(fd,JSON.stringify(result,null,2)+'\n');fsyncSync(fd);}finally{closeSync(fd);}renameSync(f,join(out,'capture.json'));const d=openSync(out,'r');try{fsyncSync(d);}finally{closeSync(d);}} checkpoint(); browser=await chromium.launch({headless:true});const context=await browser.newContext({storageState:state,acceptDownloads:true}); const page=await context.newPage(),pending=new Set(),observedArticles={},observedResources={records:{},empty:{}}; let responseFailures=0; const observer=r=>{if(!isCorpusResponse(r.url(),BASE))return;const p=(async()=>{const b=await boundedCaptureBody(r);if(b.text&&r.ok()){ingestResourceBody(b.text,observedResources);ingestCapturedBody(b.text,{},observedArticles);}else responseFailures++;})();pending.add(p);p.finally(()=>pending.delete(p));}; page.on('response',observer); async function retain(url){ if(result.files[url])return result.files[url]; const response=await fetch(url,{redirect:'error',signal:AbortSignal.timeout(90000)}); if(!response.ok||!response.body||Number(response.headers.get('content-length'))>MAX_FILE)throw Error('download_refused'); const path='files/'+sha(Buffer.from(url)),fd=openSync(join(out,path),'wx',0o600),hash=createHash('sha256');let size=0; try{for await(const chunk of response.body){size+=chunk.length;total+=chunk.length;if(size>MAX_FILE||total>MAX_TOTAL)throw Error('download_bound');hash.update(chunk);let offset=0;while(offsetdownloads.push(d); const onPage=p=>p.on('download',onDownload);page.on('download',onDownload);context.on('page',onPage); try{ await buttons.nth(i).click(); for(let n=0;n<60&&!downloads.length;n++)await page.waitForTimeout(100); if(downloads.length!==1)throw Error('single_ui_download_required'); const download=downloads[0],url=download.url();const matches=rows.filter(r=>r.url===url); if(matches.length!==1)throw Error('ui_source_binding'); const row=matches[0],file=await retain(url); const received=await download.path(); if(!received||lstatSync(received).size!==file.bytes||sha(readFileSync(received))!==file.sha256) throw Error('independent_download_byte_mismatch'); attempt.rows.push({...row,...file,suggested_filename:download.suggestedFilename(), ui_download_url_verified:true,ui_and_independent_download_bytes_match:true}); }finally{page.off('download',onDownload);context.off('page',onPage);for(const p of context.pages())if(p!==page)await p.close();} } if(new Set(attempt.rows.map(r=>r.relation_id)).size!==rows.length)throw Error('duplicate_or_missing_relation'); for(const row of rows){const key='custom.relattachment:'+row.relation_id;result.resources.records[key]=observedResources.records[key];result.articles[row.article_id]=observedArticles[row.article_id];} if(!observedArticles[id])throw Error('parent_source_missing');result.articles[id]=observedArticles[id]; result.attempts.push(attempt);checkpoint(); console.log(JSON.stringify({visited:result.attempts.length,modal_files:count,retained_files:Object.keys(result.files).length,retained_bytes:total})); } page.off('response',observer);await Promise.allSettled([...pending]); await browser.close();browser=null; if(sha(privateRead(source))!==result.source_articles_sha256)throw Error('source_changed'); result.status='completed_bounded_attachment_modal_capture';result.completed_at=new Date().toISOString();checkpoint(); console.log(JSON.stringify({status:result.status,visited:result.attempts.length,relations:Object.keys(result.resources.records).length,files:Object.keys(result.files).length,bytes:total,private_capture_sha256:sha(readFileSync(join(out,'capture.json'))),source_mutations:false,production_imported:false,resource_completeness_verified:false})); }catch(error){if(browser)await browser.close();console.error('Attachment modal capture stopped: '+(error.message||'unknown').replace(/[^a-zA-Z0-9_]/g,'_').slice(0,80));process.exitCode=1;}