This repository has no description
0

Configure Feed

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

thermals / src / explorer.js
9.3 kB 266 lines
1// SPDX-License-Identifier: AGPL-3.0-only 2// Copyright (c) 2026 sol pbc 3 4import { CAP_COLLECTION, PROFILE_COLLECTION, VOUCH_COLLECTION } from './atproto.js'; 5import { json, parseCursor, parseLimit } from './http.js'; 6import { axisCounts, rookByDid, shippedCapPredicate } from './reputation.js'; 7 8const PROFILE_CURSOR_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} did:[^\s]+$/; 9 10export async function handleExplorer(request, env, url) { 11 const { pathname } = url; 12 if (pathname === '/api/explorer/summary') return summary(env); 13 if (pathname === '/api/explorer/records') return records(url.searchParams, env); 14 if (pathname === '/api/explorer/record') return record(url.searchParams, env); 15 if (pathname === '/api/explorer/actor') return actor(url.searchParams, env); 16 return json({ error: 'not found' }, 404); 17} 18 19async function summary(env) { 20 const [capsShipped, requests, vouches, profiles] = await env.DB.batch([ 21 env.DB.prepare(`SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM caps c WHERE ${shippedCapPredicate('c')}`), 22 env.DB.prepare("SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM caps WHERE kind = 'request'"), 23 env.DB.prepare('SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM vouches'), 24 env.DB.prepare('SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM profiles'), 25 ]); 26 return json({ 27 capsShipped: countShape(capsShipped.results?.[0]), 28 requests: countShape(requests.results?.[0]), 29 vouches: countShape(vouches.results?.[0]), 30 profiles: countShape(profiles.results?.[0]), 31 }); 32} 33 34async function records(searchParams, env) { 35 const collection = searchParams.get('collection'); 36 if (!isCollection(collection)) return json({ error: 'bad collection' }, 400); 37 38 const did = searchParams.get('did'); 39 const kind = searchParams.get('kind'); 40 const limit = parseLimit(searchParams.get('limit')); 41 const cursorParam = searchParams.get('cursor'); 42 43 if (collection === CAP_COLLECTION) { 44 const conditions = []; 45 const bindings = []; 46 if (did) { conditions.push('c.did = ?'); bindings.push(did); } 47 if (kind) { conditions.push('c.kind = ?'); bindings.push(kind); } 48 const cursor = parseCursor(cursorParam); 49 if (cursor) { conditions.push('c.id < ?'); bindings.push(cursor); } 50 const { results } = await env.DB.prepare( 51 `SELECT c.*, h.handle 52 FROM caps c LEFT JOIN handles h ON c.did = h.did 53 ${whereClause(conditions)} 54 ORDER BY c.id DESC 55 LIMIT ?`, 56 ).bind(...bindings, limit).all(); 57 return json(recordsShape(results ?? [], limit, (row) => String(row.id))); 58 } 59 60 if (collection === VOUCH_COLLECTION) { 61 const conditions = []; 62 const bindings = []; 63 if (did) { conditions.push('v.did = ?'); bindings.push(did); } 64 if (kind) { conditions.push('v.kind = ?'); bindings.push(kind); } 65 const cursor = parseCursor(cursorParam); 66 if (cursor) { conditions.push('v.id < ?'); bindings.push(cursor); } 67 const { results } = await env.DB.prepare( 68 `SELECT v.*, h.handle 69 FROM vouches v LEFT JOIN handles h ON v.did = h.did 70 ${whereClause(conditions)} 71 ORDER BY v.id DESC 72 LIMIT ?`, 73 ).bind(...bindings, limit).all(); 74 return json(recordsShape(results ?? [], limit, (row) => String(row.id))); 75 } 76 77 const conditions = []; 78 const bindings = []; 79 if (did) { conditions.push('p.did = ?'); bindings.push(did); } 80 const cursor = decodeProfileCursor(cursorParam); 81 if (cursor) { 82 conditions.push('(p.indexed_at < ? OR (p.indexed_at = ? AND p.did < ?))'); 83 bindings.push(cursor.indexedAt, cursor.indexedAt, cursor.did); 84 } 85 const { results } = await env.DB.prepare( 86 `SELECT p.*, h.handle 87 FROM profiles p LEFT JOIN handles h ON p.did = h.did 88 ${whereClause(conditions)} 89 ORDER BY p.indexed_at DESC, p.did DESC 90 LIMIT ?`, 91 ).bind(...bindings, limit).all(); 92 return json(recordsShape(results ?? [], limit, (row) => encodeProfileCursor(row.indexed_at, row.did))); 93} 94 95async function record(searchParams, env) { 96 const uri = searchParams.get('uri'); 97 if (!uri) return json({ error: 'uri required' }, 400); 98 99 const cap = await env.DB.prepare( 100 `SELECT c.*, h.handle 101 FROM caps c LEFT JOIN handles h ON c.did = h.did 102 WHERE c.uri = ?`, 103 ).bind(uri).first(); 104 if (cap) { 105 const { results: vouches } = await env.DB.prepare( 106 `SELECT v.*, h.handle 107 FROM vouches v LEFT JOIN handles h ON v.did = h.did 108 WHERE v.cap_uri = ? 109 ORDER BY v.id DESC`, 110 ).bind(uri).all(); 111 return json({ 112 collection: CAP_COLLECTION, 113 record: shapeRow(cap), 114 context: { vouches: (vouches ?? []).map(shapeRow) }, 115 }); 116 } 117 118 const vouch = await env.DB.prepare( 119 `SELECT v.*, h.handle 120 FROM vouches v LEFT JOIN handles h ON v.did = h.did 121 WHERE v.uri = ?`, 122 ).bind(uri).first(); 123 if (vouch) { 124 const subject = await env.DB.prepare( 125 `SELECT c.uri, c.did, h.handle, c.title, c.ref, c.kind, c.created_at 126 FROM caps c LEFT JOIN handles h ON c.did = h.did 127 WHERE c.uri = ?`, 128 ).bind(vouch.cap_uri).first(); 129 return json({ 130 collection: VOUCH_COLLECTION, 131 record: shapeRow(vouch), 132 context: { subject: subject ?? { uri: vouch.cap_uri } }, 133 }); 134 } 135 136 const profile = await env.DB.prepare( 137 `SELECT p.*, h.handle 138 FROM profiles p LEFT JOIN handles h ON p.did = h.did 139 WHERE p.uri = ?`, 140 ).bind(uri).first(); 141 if (profile) { 142 return json({ 143 collection: PROFILE_COLLECTION, 144 record: shapeRow(profile), 145 context: { handle: profile.handle ?? null }, 146 }); 147 } 148 149 return json({ error: 'not found' }, 404); 150} 151 152async function actor(searchParams, env) { 153 let did = searchParams.get('did'); 154 const handle = searchParams.get('handle'); 155 let handleRow = null; 156 157 if (!did && !handle) return json({ error: 'did or handle required' }, 400); 158 if (!did && handle) { 159 handleRow = await env.DB.prepare('SELECT did, handle FROM handles WHERE handle = ?').bind(handle).first(); 160 if (!handleRow) return json({ error: 'not found' }, 404); 161 did = handleRow.did; 162 } else if (did) { 163 handleRow = await env.DB.prepare('SELECT did, handle FROM handles WHERE did = ?').bind(did).first(); 164 } 165 166 const presence = await env.DB.prepare( 167 `SELECT 1 found FROM profiles WHERE did = ? 168 UNION ALL SELECT 1 FROM caps WHERE did = ? 169 UNION ALL SELECT 1 FROM vouches WHERE did = ? 170 LIMIT 1`, 171 ).bind(did, did, did).first(); 172 if (!presence) return json({ error: 'not found' }, 404); 173 174 const [profile, counts, capsShipped, endorsementsReceived, vouchesGiven] = await Promise.all([ 175 rookByDid(env, did), 176 axisCounts(env, did), 177 env.DB.prepare( 178 `SELECT c.*, h.handle 179 FROM caps c LEFT JOIN handles h ON c.did = h.did 180 WHERE c.did = ? AND ${shippedCapPredicate('c')} 181 ORDER BY c.id DESC`, 182 ).bind(did).all(), 183 env.DB.prepare( 184 `SELECT v.*, hv.handle, c.title AS cap_title, c.ref AS cap_ref, c.kind AS cap_kind 185 FROM vouches v 186 JOIN caps c ON v.cap_uri = c.uri 187 LEFT JOIN handles hv ON v.did = hv.did 188 WHERE v.kind = 'endorse' AND c.did = ? AND ${shippedCapPredicate('c')} 189 ORDER BY v.id DESC`, 190 ).bind(did).all(), 191 env.DB.prepare( 192 `SELECT v.*, h.handle, c.title AS cap_title, c.ref AS cap_ref, c.kind AS cap_kind 193 FROM vouches v 194 LEFT JOIN handles h ON v.did = h.did 195 LEFT JOIN caps c ON v.cap_uri = c.uri 196 WHERE v.did = ? AND v.kind = 'endorse' 197 ORDER BY v.id DESC`, 198 ).bind(did).all(), 199 ]); 200 201 return json({ 202 did, 203 handle: profile?.handle ?? handleRow?.handle ?? handle ?? null, 204 profile, 205 counts, 206 records: { 207 capsShipped: (capsShipped.results ?? []).map(shapeRow), 208 endorsementsReceived: (endorsementsReceived.results ?? []).map(shapeRow), 209 vouchesGiven: (vouchesGiven.results ?? []).map(shapeRow), 210 }, 211 }); 212} 213 214function isCollection(collection) { 215 return collection === CAP_COLLECTION || collection === VOUCH_COLLECTION || collection === PROFILE_COLLECTION; 216} 217 218function countShape(row) { 219 return { 220 count: row?.count ?? 0, 221 latestIndexedAt: row?.latestIndexedAt ?? null, 222 }; 223} 224 225function recordsShape(rows, limit, cursorFor) { 226 const records = rows.map(shapeRow); 227 return { 228 records, 229 cursor: rows.length < limit || rows.length === 0 ? null : cursorFor(rows[rows.length - 1]), 230 }; 231} 232 233function whereClause(conditions) { 234 return conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; 235} 236 237function shapeRow(row) { 238 const out = { ...row, value: parseRecordJson(row.record_json) }; 239 return out; 240} 241 242function parseRecordJson(value) { 243 if (!value) return null; 244 try { 245 return JSON.parse(value); 246 } catch { 247 return null; 248 } 249} 250 251function encodeProfileCursor(indexedAt, did) { 252 if (!indexedAt || !did) return null; 253 return btoa(`${indexedAt} ${did}`).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); 254} 255 256function decodeProfileCursor(token) { 257 if (!token) return null; 258 try { 259 const padded = token.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (token.length % 4)) % 4); 260 const decoded = atob(padded); 261 if (!PROFILE_CURSOR_RE.test(decoded)) return null; 262 return { indexedAt: decoded.slice(0, 19), did: decoded.slice(20) }; 263 } catch { 264 return null; 265 } 266}