webpeel 0.20.21 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -229,7 +229,10 @@ export function createFetchRouter(authStore) {
229
229
  const cacheAge = Date.now() - cached.timestamp;
230
230
  if (cacheAge < maxAgeMs && cacheAge < cacheTtlMs) {
231
231
  res.setHeader('X-Cache', 'HIT');
232
+ res.setHeader('X-Cache-Status', 'HIT');
232
233
  res.setHeader('X-Cache-Age', Math.floor(cacheAge / 1000).toString());
234
+ // Cache-Control: allow Cloudflare edge to cache successful GET responses
235
+ res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
233
236
  if (wantsEnvelope(req)) {
234
237
  successResponse(res, cached.result, {
235
238
  requestId: req.requestId,
@@ -467,9 +470,19 @@ export function createFetchRouter(authStore) {
467
470
  : undefined;
468
471
  // Add usage headers (kept for backward compat; also surfaced in envelope metadata)
469
472
  res.setHeader('X-Cache', 'MISS');
473
+ res.setHeader('X-Cache-Status', 'MISS');
470
474
  res.setHeader('X-Credits-Used', '1');
471
475
  res.setHeader('X-Processing-Time', elapsed.toString());
472
476
  res.setHeader('X-Fetch-Type', fetchType);
477
+ // Cache-Control: allow Cloudflare edge to cache successful GET responses for 60s
478
+ res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
479
+ // Response timing headers — let customers see exactly where time is spent
480
+ const timingFetch = result.timing?.fetch ?? 0;
481
+ const timingParse = (result.timing?.convert ?? 0) + (result.timing?.metadata ?? 0) + (result.timing?.prune ?? 0);
482
+ res.setHeader('X-Response-Time', `${elapsed}ms`);
483
+ res.setHeader('X-Fetch-Time', `${timingFetch}ms`);
484
+ res.setHeader('X-Parse-Time', `${timingParse}ms`);
485
+ res.setHeader('Server-Timing', `fetch;dur=${timingFetch}, parse;dur=${timingParse}, total;dur=${elapsed}`);
473
486
  // Build response — extend result with optional answer/summary fields
474
487
  const getResponseBody = { ...result };
475
488
  if (getAnswerResult !== undefined)
@@ -676,6 +689,7 @@ export function createFetchRouter(authStore) {
676
689
  const cacheAge = Date.now() - cached.timestamp;
677
690
  if (cacheAge < postCacheTtlMs) {
678
691
  res.setHeader('X-Cache', 'HIT');
692
+ res.setHeader('X-Cache-Status', 'HIT');
679
693
  res.setHeader('X-Cache-Age', Math.floor(cacheAge / 1000).toString());
680
694
  if (wantsEnvelope(req)) {
681
695
  successResponse(res, cached.result, {
@@ -932,9 +946,17 @@ export function createFetchRouter(authStore) {
932
946
  // --- Build response ------------------------------------------------------
933
947
  // Headers kept for backward compat; also surfaced in envelope metadata.
934
948
  res.setHeader('X-Cache', 'MISS');
949
+ res.setHeader('X-Cache-Status', 'MISS');
935
950
  res.setHeader('X-Credits-Used', '1');
936
951
  res.setHeader('X-Processing-Time', elapsed.toString());
937
952
  res.setHeader('X-Fetch-Type', fetchType);
953
+ // Response timing headers — let customers see exactly where time is spent
954
+ const postTimingFetch = result.timing?.fetch ?? 0;
955
+ const postTimingParse = (result.timing?.convert ?? 0) + (result.timing?.metadata ?? 0) + (result.timing?.prune ?? 0);
956
+ res.setHeader('X-Response-Time', `${elapsed}ms`);
957
+ res.setHeader('X-Fetch-Time', `${postTimingFetch}ms`);
958
+ res.setHeader('X-Parse-Time', `${postTimingParse}ms`);
959
+ res.setHeader('Server-Timing', `fetch;dur=${postTimingFetch}, parse;dur=${postTimingParse}, total;dur=${elapsed}`);
938
960
  const responseBody = { ...result };
939
961
  if (jsonData !== undefined) {
940
962
  responseBody.json = jsonData;
@@ -96,6 +96,11 @@ export function createReaderRouter() {
96
96
  selector: targetSelector,
97
97
  waitSelector: waitForSelector,
98
98
  });
99
+ // Cache-Control: this endpoint is public and heavily cacheable.
100
+ // Cloudflare edge caches for 2 min; serves stale for up to 10 min while revalidating.
101
+ res.setHeader('Cache-Control', 'public, s-maxage=120, stale-while-revalidate=600');
102
+ // Vary on Accept so different content-type representations are cached separately.
103
+ res.setHeader('Vary', 'Accept');
99
104
  // Return based on format
100
105
  const responseFormat = format.toLowerCase();
101
106
  if (responseFormat === 'text') {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shareable read links — short public URLs for fetched content
3
+ *
4
+ * POST /v1/share — create a short link (auth required, 50/day limit)
5
+ * GET /s/:id — serve shared content (public, no auth)
6
+ *
7
+ * IDs are 9-char base64url strings (crypto.randomBytes(6).toString('base64url').slice(0, 9))
8
+ * Shares expire after 30 days. view_count is incremented on every public read.
9
+ */
10
+ import { Router } from 'express';
11
+ import pg from 'pg';
12
+ /** Generate a cryptographically secure 9-char base64url ID.
13
+ * randomBytes(7) → base64url gives 10 chars (7*4/3=9.33→10), slice to 9.
14
+ * Note: randomBytes(6) → base64url gives only 8 chars (6/3*4=8), so we need 7+ bytes.
15
+ */
16
+ export declare function generateShareId(): string;
17
+ export declare function createSharePublicRouter(pool: pg.Pool | null): Router;
18
+ export declare function createShareRouter(pool: pg.Pool | null): Router;
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Shareable read links — short public URLs for fetched content
3
+ *
4
+ * POST /v1/share — create a short link (auth required, 50/day limit)
5
+ * GET /s/:id — serve shared content (public, no auth)
6
+ *
7
+ * IDs are 9-char base64url strings (crypto.randomBytes(6).toString('base64url').slice(0, 9))
8
+ * Shares expire after 30 days. view_count is incremented on every public read.
9
+ */
10
+ import { Router } from 'express';
11
+ import crypto from 'crypto';
12
+ import { createLogger } from '../logger.js';
13
+ import { peel } from '../../index.js';
14
+ import { validateUrlForSSRF, SSRFError } from '../middleware/url-validator.js';
15
+ const log = createLogger('share');
16
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
17
+ /** Generate a cryptographically secure 9-char base64url ID.
18
+ * randomBytes(7) → base64url gives 10 chars (7*4/3=9.33→10), slice to 9.
19
+ * Note: randomBytes(6) → base64url gives only 8 chars (6/3*4=8), so we need 7+ bytes.
20
+ */
21
+ export function generateShareId() {
22
+ return crypto.randomBytes(7).toString('base64url').slice(0, 9);
23
+ }
24
+ /** Base URL for share links */
25
+ function getBaseUrl() {
26
+ return process.env.API_BASE_URL || 'https://api.webpeel.dev';
27
+ }
28
+ /** Simple markdown → HTML renderer (no external deps) */
29
+ function markdownToHtml(md) {
30
+ let html = md
31
+ // Escape raw HTML in content to prevent XSS
32
+ .replace(/&/g, '&amp;')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;')
35
+ // Code blocks (``` ... ```)
36
+ .replace(/```[\w]*\n([\s\S]*?)```/g, (_m, code) => `<pre><code>${code.trim()}</code></pre>`)
37
+ // Inline code
38
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
39
+ // Bold + italic
40
+ .replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>')
41
+ // Bold
42
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
43
+ // Italic
44
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>')
45
+ // Headings
46
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
47
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
48
+ .replace(/^# (.+)$/gm, '<h1>$1</h1>')
49
+ // Horizontal rule
50
+ .replace(/^---$/gm, '<hr>')
51
+ // Blockquote
52
+ .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
53
+ // Unordered list items
54
+ .replace(/^[\*\-] (.+)$/gm, '<li>$1</li>')
55
+ // Ordered list items
56
+ .replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
57
+ // Links
58
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>')
59
+ // Images
60
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
61
+ // Double newlines → paragraph breaks
62
+ .replace(/\n\n+/g, '\n</p><p>\n')
63
+ // Remaining single newlines → <br>
64
+ .replace(/\n/g, '<br>\n');
65
+ // Wrap consecutive <li> items in <ul>
66
+ html = html.replace(/(<li>.*?<\/li>\n?)+/gs, (m) => `<ul>\n${m}</ul>\n`);
67
+ return `<p>\n${html}\n</p>`;
68
+ }
69
+ /** Build the full HTML page for a shared read */
70
+ function buildHtmlPage(share) {
71
+ const title = share.title ? `${share.title} — WebPeel` : 'Shared Read — WebPeel';
72
+ const description = share.content.slice(0, 200).replace(/\n/g, ' ').replace(/"/g, '&quot;') + '…';
73
+ const canonicalUrl = `${getBaseUrl()}/s/${share.id}`;
74
+ const originalUrl = share.url
75
+ .replace(/&/g, '&amp;')
76
+ .replace(/</g, '&lt;')
77
+ .replace(/>/g, '&gt;');
78
+ const bodyHtml = markdownToHtml(share.content);
79
+ return `<!DOCTYPE html>
80
+ <html lang="en">
81
+ <head>
82
+ <meta charset="UTF-8">
83
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
84
+ <title>${title.replace(/</g, '&lt;')}</title>
85
+ <meta name="description" content="${description}">
86
+ <link rel="canonical" href="${canonicalUrl}">
87
+
88
+ <!-- Open Graph -->
89
+ <meta property="og:title" content="${(share.title || 'Shared Read').replace(/</g, '&lt;')}">
90
+ <meta property="og:description" content="${description}">
91
+ <meta property="og:url" content="${canonicalUrl}">
92
+ <meta property="og:type" content="article">
93
+ <meta property="og:site_name" content="WebPeel">
94
+
95
+ <!-- Twitter Card -->
96
+ <meta name="twitter:card" content="summary">
97
+ <meta name="twitter:title" content="${(share.title || 'Shared Read').replace(/</g, '&lt;')}">
98
+ <meta name="twitter:description" content="${description}">
99
+ <meta name="twitter:site" content="@webpeel">
100
+
101
+ <style>
102
+ *, *::before, *::after { box-sizing: border-box; }
103
+ :root {
104
+ --bg: #0f0f11;
105
+ --surface: #1a1a1f;
106
+ --border: #2a2a35;
107
+ --text: #e4e4e7;
108
+ --muted: #71717a;
109
+ --accent: #818cf8;
110
+ --link: #6366f1;
111
+ --code-bg: #1e1e28;
112
+ --max-w: 760px;
113
+ }
114
+ html { background: var(--bg); }
115
+ body {
116
+ margin: 0;
117
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
118
+ font-size: 16px;
119
+ line-height: 1.75;
120
+ color: var(--text);
121
+ background: var(--bg);
122
+ padding: 0 16px;
123
+ }
124
+
125
+ /* Top bar */
126
+ .topbar {
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: space-between;
130
+ max-width: var(--max-w);
131
+ margin: 0 auto;
132
+ padding: 20px 0 16px;
133
+ border-bottom: 1px solid var(--border);
134
+ gap: 12px;
135
+ flex-wrap: wrap;
136
+ }
137
+ .logo { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); }
138
+ .logo-mark {
139
+ width: 28px; height: 28px;
140
+ background: var(--accent);
141
+ border-radius: 7px;
142
+ display: flex; align-items: center; justify-content: center;
143
+ font-size: 14px; font-weight: 700; color: #fff; letter-spacing: -0.5px;
144
+ }
145
+ .logo-name { font-weight: 600; font-size: 15px; }
146
+ .source-link {
147
+ font-size: 12px; color: var(--muted);
148
+ text-decoration: none; max-width: 300px;
149
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
150
+ }
151
+ .source-link:hover { color: var(--accent); }
152
+
153
+ /* Main content */
154
+ main {
155
+ max-width: var(--max-w);
156
+ margin: 32px auto;
157
+ }
158
+ h1 { font-size: 1.75rem; font-weight: 700; line-height: 1.25; margin: 0 0 24px; color: var(--text); }
159
+ h2 { font-size: 1.35rem; font-weight: 600; margin: 28px 0 12px; color: var(--text); }
160
+ h3 { font-size: 1.1rem; font-weight: 600; margin: 24px 0 10px; color: var(--text); }
161
+ p { margin: 0 0 16px; color: #d4d4d8; }
162
+ a { color: var(--link); text-decoration: underline; text-underline-offset: 3px; }
163
+ a:hover { color: var(--accent); }
164
+ ul, ol { padding-left: 24px; margin: 0 0 16px; }
165
+ li { margin-bottom: 6px; color: #d4d4d8; }
166
+ blockquote {
167
+ border-left: 3px solid var(--accent); margin: 16px 0;
168
+ padding: 4px 16px; color: var(--muted); font-style: italic;
169
+ }
170
+ code {
171
+ background: var(--code-bg); padding: 2px 6px; border-radius: 4px;
172
+ font-family: 'Fira Code', 'Cascadia Code', monospace; font-size: 0.875em;
173
+ color: var(--accent);
174
+ }
175
+ pre {
176
+ background: var(--code-bg); padding: 16px; border-radius: 8px;
177
+ overflow-x: auto; margin: 16px 0; border: 1px solid var(--border);
178
+ }
179
+ pre code { background: none; padding: 0; color: #e4e4e7; }
180
+ img { max-width: 100%; border-radius: 6px; margin: 8px 0; }
181
+ hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
182
+
183
+ /* Meta info */
184
+ .meta {
185
+ display: flex; gap: 16px; flex-wrap: wrap;
186
+ font-size: 12px; color: var(--muted);
187
+ margin-bottom: 28px; padding-bottom: 20px;
188
+ border-bottom: 1px solid var(--border);
189
+ }
190
+ .meta span { display: flex; align-items: center; gap: 4px; }
191
+
192
+ /* Footer */
193
+ footer {
194
+ max-width: var(--max-w);
195
+ margin: 48px auto 32px;
196
+ padding-top: 24px;
197
+ border-top: 1px solid var(--border);
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: space-between;
201
+ gap: 12px;
202
+ flex-wrap: wrap;
203
+ }
204
+ .footer-left { font-size: 13px; color: var(--muted); }
205
+ .cta-btn {
206
+ display: inline-flex; align-items: center; gap-6px;
207
+ padding: 8px 16px; border-radius: 8px;
208
+ background: var(--accent); color: #fff;
209
+ font-size: 13px; font-weight: 600;
210
+ text-decoration: none; transition: opacity 0.15s;
211
+ }
212
+ .cta-btn:hover { opacity: 0.85; color: #fff; }
213
+
214
+ @media (max-width: 600px) {
215
+ h1 { font-size: 1.4rem; }
216
+ .topbar { flex-direction: column; align-items: flex-start; }
217
+ }
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <!-- Top bar -->
222
+ <div class="topbar">
223
+ <a class="logo" href="https://webpeel.dev" target="_blank" rel="noopener">
224
+ <div class="logo-mark">W</div>
225
+ <span class="logo-name">WebPeel</span>
226
+ </a>
227
+ <a class="source-link" href="${originalUrl}" target="_blank" rel="noopener noreferrer" title="${originalUrl}">
228
+ ↗ ${originalUrl}
229
+ </a>
230
+ </div>
231
+
232
+ <!-- Article -->
233
+ <main>
234
+ ${share.title ? `<h1>${share.title.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</h1>` : ''}
235
+ <div class="meta">
236
+ ${share.tokens != null ? `<span>📝 ${share.tokens.toLocaleString()} tokens</span>` : ''}
237
+ <span>👁 ${share.view_count.toLocaleString()} views</span>
238
+ <span>⏰ Expires ${new Date(share.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
239
+ </div>
240
+ <div class="content">
241
+ ${bodyHtml}
242
+ </div>
243
+ </main>
244
+
245
+ <!-- Footer -->
246
+ <footer>
247
+ <span class="footer-left">Powered by <a href="https://webpeel.dev" target="_blank" rel="noopener">WebPeel</a> — clean web reading for humans &amp; AI</span>
248
+ <a class="cta-btn" href="https://app.webpeel.dev" target="_blank" rel="noopener">
249
+ Try WebPeel →
250
+ </a>
251
+ </footer>
252
+ </body>
253
+ </html>`;
254
+ }
255
+ // ─── Rate limit: 50 shares per day per user ───────────────────────────────────
256
+ const shareRateMap = new Map();
257
+ const SHARE_DAY_LIMIT = 50;
258
+ const SHARE_DAY_MS = 24 * 60 * 60 * 1000;
259
+ function checkShareRateLimit(userId) {
260
+ const now = Date.now();
261
+ const entry = shareRateMap.get(userId);
262
+ if (!entry || entry.resetAt < now) {
263
+ shareRateMap.set(userId, { count: 1, resetAt: now + SHARE_DAY_MS });
264
+ return { allowed: true, remaining: SHARE_DAY_LIMIT - 1 };
265
+ }
266
+ entry.count++;
267
+ if (entry.count > SHARE_DAY_LIMIT) {
268
+ return { allowed: false, remaining: 0 };
269
+ }
270
+ return { allowed: true, remaining: SHARE_DAY_LIMIT - entry.count };
271
+ }
272
+ // ─── Public router: GET /s/:id ────────────────────────────────────────────────
273
+ export function createSharePublicRouter(pool) {
274
+ const router = Router();
275
+ router.get('/s/:id', async (req, res, next) => {
276
+ const id = String(req.params['id'] || '');
277
+ // Only intercept valid-looking 9-char base64url IDs
278
+ if (!/^[A-Za-z0-9_-]{9}$/.test(id)) {
279
+ return next();
280
+ }
281
+ if (!pool) {
282
+ // No DB: fall through to reader's search handler
283
+ return next();
284
+ }
285
+ try {
286
+ // Fetch share and increment view count atomically
287
+ const result = await pool.query(`UPDATE shared_reads
288
+ SET view_count = view_count + 1
289
+ WHERE id = $1
290
+ AND expires_at > NOW()
291
+ RETURNING id, url, title, content, tokens, created_at, expires_at, view_count`, [id]);
292
+ if (result.rows.length === 0) {
293
+ // Not found or expired — fall through to reader's /s/* search handler
294
+ return next();
295
+ }
296
+ const share = result.rows[0];
297
+ // Respond based on Accept header
298
+ const accept = req.headers.accept || '';
299
+ if (accept.includes('application/json')) {
300
+ return res.json({
301
+ success: true,
302
+ shareId: share.id,
303
+ url: share.url,
304
+ title: share.title,
305
+ content: share.content,
306
+ tokens: share.tokens,
307
+ viewCount: share.view_count,
308
+ createdAt: share.created_at,
309
+ expiresAt: share.expires_at,
310
+ });
311
+ }
312
+ if (accept.includes('text/markdown')) {
313
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
314
+ return res.send(share.content);
315
+ }
316
+ // Default: return HTML page (also covers text/html)
317
+ // Override CSP to allow inline styles for the share page
318
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; " +
319
+ "frame-ancestors 'none'; base-uri 'none'; form-action 'none'; " +
320
+ "script-src 'none'");
321
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
322
+ res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
323
+ return res.send(buildHtmlPage(share));
324
+ }
325
+ catch (err) {
326
+ log.error('Share GET error:', err.message);
327
+ return res.status(500).json({
328
+ success: false,
329
+ error: { type: 'server_error', message: 'Failed to retrieve share' },
330
+ });
331
+ }
332
+ });
333
+ return router;
334
+ }
335
+ // ─── Protected router: POST /v1/share ─────────────────────────────────────────
336
+ export function createShareRouter(pool) {
337
+ const router = Router();
338
+ router.post('/v1/share', async (req, res) => {
339
+ // Require auth
340
+ const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
341
+ if (!userId) {
342
+ return res.status(401).json({
343
+ success: false,
344
+ error: {
345
+ type: 'unauthorized',
346
+ message: 'Authentication required to create share links.',
347
+ hint: 'Include an Authorization: Bearer <token> header.',
348
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
349
+ },
350
+ });
351
+ }
352
+ if (!pool) {
353
+ return res.status(503).json({
354
+ success: false,
355
+ error: {
356
+ type: 'unavailable',
357
+ message: 'Share links require a PostgreSQL database.',
358
+ },
359
+ });
360
+ }
361
+ // Rate limit: 50 shares per day per user
362
+ const { allowed, remaining } = checkShareRateLimit(userId);
363
+ res.setHeader('X-Share-Limit-Remaining', remaining.toString());
364
+ if (!allowed) {
365
+ return res.status(429).json({
366
+ success: false,
367
+ error: {
368
+ type: 'rate_limited',
369
+ message: 'Share limit exceeded. Maximum 50 shares per day.',
370
+ hint: 'Wait until tomorrow to create more share links.',
371
+ },
372
+ });
373
+ }
374
+ const { url, content, title } = req.body;
375
+ if (!url || typeof url !== 'string') {
376
+ return res.status(400).json({
377
+ success: false,
378
+ error: {
379
+ type: 'invalid_request',
380
+ message: 'url is required.',
381
+ },
382
+ });
383
+ }
384
+ // SECURITY: SSRF validation
385
+ try {
386
+ validateUrlForSSRF(url);
387
+ }
388
+ catch (err) {
389
+ if (err instanceof SSRFError) {
390
+ return res.status(400).json({
391
+ success: false,
392
+ error: { type: 'ssrf_blocked', message: err.message },
393
+ });
394
+ }
395
+ throw err;
396
+ }
397
+ let shareContent;
398
+ let shareTitle;
399
+ let tokens;
400
+ if (content && typeof content === 'string') {
401
+ // Content provided directly (user already fetched it in dashboard)
402
+ shareContent = content;
403
+ shareTitle = title;
404
+ tokens = content.split(/\s+/).filter(Boolean).length;
405
+ }
406
+ else {
407
+ // Fetch the URL via peel()
408
+ try {
409
+ const result = await peel(url, { timeout: 15000, noEscalate: true });
410
+ shareContent = result.content || '';
411
+ shareTitle = result.title;
412
+ tokens = result.tokens ?? undefined;
413
+ }
414
+ catch (err) {
415
+ log.error('Share: peel failed', { url, error: err.message });
416
+ return res.status(422).json({
417
+ success: false,
418
+ error: {
419
+ type: 'fetch_failed',
420
+ message: `Failed to fetch URL: ${err.message}`,
421
+ },
422
+ });
423
+ }
424
+ }
425
+ if (!shareContent) {
426
+ return res.status(422).json({
427
+ success: false,
428
+ error: {
429
+ type: 'empty_content',
430
+ message: 'No content could be extracted from the URL.',
431
+ },
432
+ });
433
+ }
434
+ // Generate a unique ID with retry for collisions (extremely rare)
435
+ let shareId = '';
436
+ for (let attempt = 0; attempt < 5; attempt++) {
437
+ const candidate = generateShareId();
438
+ const exists = await pool.query('SELECT 1 FROM shared_reads WHERE id = $1', [candidate]);
439
+ if (exists.rows.length === 0) {
440
+ shareId = candidate;
441
+ break;
442
+ }
443
+ }
444
+ if (!shareId) {
445
+ return res.status(500).json({
446
+ success: false,
447
+ error: { type: 'server_error', message: 'Failed to generate unique share ID.' },
448
+ });
449
+ }
450
+ // Insert share into DB
451
+ await pool.query(`INSERT INTO shared_reads (id, url, title, content, tokens, created_by)
452
+ VALUES ($1, $2, $3, $4, $5, $6)`, [shareId, url, shareTitle ?? null, shareContent, tokens ?? null, userId]);
453
+ const shareUrl = `${getBaseUrl()}/s/${shareId}`;
454
+ log.info('Share created', { shareId, url, userId });
455
+ return res.status(201).json({
456
+ success: true,
457
+ shareId,
458
+ shareUrl,
459
+ });
460
+ });
461
+ return router;
462
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Transcript export endpoint
3
+ *
4
+ * GET /v1/transcript/export?url=<youtube_url>&format=srt|txt|md|json
5
+ *
6
+ * Downloads a YouTube transcript in the requested format with appropriate
7
+ * Content-Type and Content-Disposition headers.
8
+ */
9
+ import { Router } from 'express';
10
+ export declare function createTranscriptExportRouter(): Router;