aboutsummaryrefslogtreecommitdiffstats
path: root/tools/pic-proxy-test.js
blob: 881867c489ced5266ab318540732602b56286be7 (plain) (blame)
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
// A picture from a host that won't hand over its bytes (no CORS) paints from
// the server's column copy (vmedia pic.webp) instead of the original, and the
// copy is kept in the media-thumb cache for the next visit. A GIF is never
// asked for (the copy would be a still); a picture the server has no copy for
// paints the original as before. The server is stood in for: its answers are
// canned.
// Run: bun tools/pic-proxy-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const still = (args) => {
  const p = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + args.size, '-frames:v', '1', '-f', 'image2', '-c:v', args.codec, 'pipe:1']);
  if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
  return p.stdout;
};
const ORIGINAL = still({ size: '640x480', codec: 'mjpeg' }); // stands in for the camera original
const COPY = still({ size: '320x240', codec: 'libwebp' }); // the server's column copy
const GIF = still({ size: '64x64', codec: 'gif' });

const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
const PIC = { copied: 'https://pics.test/camera.jpg', gif: 'https://pics.test/loop.gif', lost: 'https://pics.test/odd.png' };
const seeded = [post('garden ' + PIC.copied, 100), post('loop ' + PIC.gif, 200), post('broken ' + PIC.lost, 300)];
const PIC_URL = (u, s = 'col') => 'https://v3.coinos.io/vmedia/pic.webp?s=' + s + '&u=' + encodeURIComponent(u);

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
  return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setRequestInterception(true);
const asked = []; // [kind, url]
page.on('request', (r) => {
  const u = new URL(r.url());
  if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) {
    const src = u.searchParams.get('u');
    asked.push([u.pathname + '?s=' + u.searchParams.get('s'), src]);
    if (src === PIC.lost) return r.respond({ status: 404, body: 'No picture' });
    return r.respond({ status: 200, contentType: 'image/webp', body: COPY, headers: { 'cache-control': 'public, max-age=604800', 'access-control-allow-origin': '*' } });
  }
  if (u.host === 'pics.test') {
    asked.push([r.resourceType(), r.url()]);
    // no CORS header: a fetch from the page is refused, an <img> paints it
    if (r.url() === PIC.gif) return r.respond({ status: 200, contentType: 'image/gif', body: GIF });
    return r.respond({ status: 200, contentType: r.url().endsWith('.png') ? 'image/png' : 'image/jpeg', body: ORIGINAL });
  }
  r.continue();
});
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
const pic = (word) => page.evaluate((w) => {
  const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.textContent.includes(w));
  const img = row && row.querySelector('img.note-img');
  return img && { src: img.getAttribute('src'), w: img.naturalWidth, h: img.naturalHeight, late: img.classList.contains('note-img-late'), ar: img.style.aspectRatio };
}, word);

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => {
    localStorage.setItem('btc-wallet-network', 'regtest');
    // the host is already known to keep its bytes (two refusals, a week ago)
    localStorage.setItem('coinos-thumb-nocors-v5', JSON.stringify({ 'pics.test': { n: 2, until: Date.now() + 86400000 } }));
  });
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('create a new wallet'); await sleep(600);
  await page.waitForSelector('.words .w .t');
  const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
  await click('skip verification');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mn + '\n');
  await page.evaluate(([k, pk, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, AUTHOR, seeded]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  check('the three pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000));

  console.log('\n[a host that keeps its bytes]');
  // the cached rows paint at once (the original, lazily); the prepared paint
  // that follows the warm swaps the copy in
  await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].some((i) => i.getAttribute('src').startsWith('blob:')), null, 15000);
  const p0 = await pic('garden');
  check('the row paints the server\'s copy, not the original', p0 && p0.src.startsWith('blob:') && p0.w === 320, JSON.stringify(p0));
  check('…with the box shaped like the picture', p0 && /^(320 \/ 240|4 \/ 3)$/.test(p0.ar), p0 && p0.ar);
  check('the server was asked for the column copy', asked.some(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.copied));
  check('the original was never fetched by script', !asked.some(([k, u]) => k === 'fetch' && u === PIC.copied), JSON.stringify(asked.filter(([, u]) => u === PIC.copied)));
  const kept = await page.evaluate(async (u) => {
    const r = await (await caches.open('coinos-media-thumbs-v1')).match(u);
    return r && { dims: r.headers.get('x-dims'), original: r.headers.get('x-original'), type: (await r.blob()).type };
  }, PIC.copied);
  check('the copy is kept for the next visit', kept && kept.original === '0' && kept.type === 'image/webp' && /320/.test(kept.dims), JSON.stringify(kept));

  console.log('\n[a GIF]');
  const p1 = await pic('loop');
  check('a GIF paints the original (a copy would be a still)', p1 && p1.src === PIC.gif, JSON.stringify(p1));
  check('…and the server was not asked', !asked.some(([k, u]) => k.startsWith('/vmedia/pic.webp') && u === PIC.gif));

  console.log('\n[the server has no copy]');
  const p2 = await pic('broken');
  check('the original paints as before', p2 && p2.src === PIC.lost && p2.w === 640, JSON.stringify(p2));
  check('the server was asked once', asked.filter(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.lost).length === 1);

  console.log('\n[the next visit]');
  const before = asked.length;
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000);
  const p3 = await pic('garden');
  check('the kept copy paints without asking the server again', p3 && p3.src.startsWith('blob:') && p3.w === 320
    && !asked.slice(before).some(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.copied), JSON.stringify(p3));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ pictures from hosts without CORS paint from the server\'s copy' : '\n❌ failed');
process.exit(ok ? 0 : 1);