webpeel 0.20.2 → 0.20.3
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.
- package/dist/server/app.d.ts +14 -0
- package/dist/server/app.js +384 -0
- package/dist/server/auth-store.d.ts +27 -0
- package/dist/server/auth-store.js +88 -0
- package/dist/server/email-service.d.ts +21 -0
- package/dist/server/email-service.js +79 -0
- package/dist/server/job-queue.d.ts +100 -0
- package/dist/server/job-queue.js +145 -0
- package/dist/server/logger.d.ts +10 -0
- package/dist/server/logger.js +37 -0
- package/dist/server/middleware/auth.d.ts +28 -0
- package/dist/server/middleware/auth.js +221 -0
- package/dist/server/middleware/rate-limit.d.ts +24 -0
- package/dist/server/middleware/rate-limit.js +167 -0
- package/dist/server/middleware/url-validator.d.ts +15 -0
- package/dist/server/middleware/url-validator.js +186 -0
- package/dist/server/openapi.yaml +6418 -0
- package/dist/server/pg-auth-store.d.ts +132 -0
- package/dist/server/pg-auth-store.js +472 -0
- package/dist/server/pg-job-queue.d.ts +59 -0
- package/dist/server/pg-job-queue.js +375 -0
- package/dist/server/premium/domain-intel.d.ts +16 -0
- package/dist/server/premium/domain-intel.js +133 -0
- package/dist/server/premium/index.d.ts +17 -0
- package/dist/server/premium/index.js +35 -0
- package/dist/server/premium/swr-cache.d.ts +14 -0
- package/dist/server/premium/swr-cache.js +34 -0
- package/dist/server/routes/activity.d.ts +6 -0
- package/dist/server/routes/activity.js +74 -0
- package/dist/server/routes/answer.d.ts +5 -0
- package/dist/server/routes/answer.js +125 -0
- package/dist/server/routes/ask.d.ts +28 -0
- package/dist/server/routes/ask.js +229 -0
- package/dist/server/routes/batch.d.ts +6 -0
- package/dist/server/routes/batch.js +493 -0
- package/dist/server/routes/cli-usage.d.ts +6 -0
- package/dist/server/routes/cli-usage.js +127 -0
- package/dist/server/routes/compat.d.ts +23 -0
- package/dist/server/routes/compat.js +652 -0
- package/dist/server/routes/deep-fetch.d.ts +8 -0
- package/dist/server/routes/deep-fetch.js +57 -0
- package/dist/server/routes/demo.d.ts +24 -0
- package/dist/server/routes/demo.js +517 -0
- package/dist/server/routes/do.d.ts +8 -0
- package/dist/server/routes/do.js +72 -0
- package/dist/server/routes/extract.d.ts +8 -0
- package/dist/server/routes/extract.js +235 -0
- package/dist/server/routes/fetch.d.ts +7 -0
- package/dist/server/routes/fetch.js +999 -0
- package/dist/server/routes/health.d.ts +7 -0
- package/dist/server/routes/health.js +19 -0
- package/dist/server/routes/jobs.d.ts +7 -0
- package/dist/server/routes/jobs.js +573 -0
- package/dist/server/routes/mcp.d.ts +14 -0
- package/dist/server/routes/mcp.js +141 -0
- package/dist/server/routes/oauth.d.ts +9 -0
- package/dist/server/routes/oauth.js +396 -0
- package/dist/server/routes/playground.d.ts +17 -0
- package/dist/server/routes/playground.js +283 -0
- package/dist/server/routes/screenshot.d.ts +22 -0
- package/dist/server/routes/screenshot.js +816 -0
- package/dist/server/routes/search.d.ts +6 -0
- package/dist/server/routes/search.js +303 -0
- package/dist/server/routes/session.d.ts +15 -0
- package/dist/server/routes/session.js +397 -0
- package/dist/server/routes/stats.d.ts +6 -0
- package/dist/server/routes/stats.js +71 -0
- package/dist/server/routes/stripe.d.ts +15 -0
- package/dist/server/routes/stripe.js +294 -0
- package/dist/server/routes/users.d.ts +8 -0
- package/dist/server/routes/users.js +1671 -0
- package/dist/server/routes/watch.d.ts +15 -0
- package/dist/server/routes/watch.js +309 -0
- package/dist/server/routes/webhooks.d.ts +26 -0
- package/dist/server/routes/webhooks.js +170 -0
- package/dist/server/routes/youtube.d.ts +6 -0
- package/dist/server/routes/youtube.js +130 -0
- package/dist/server/sentry.d.ts +13 -0
- package/dist/server/sentry.js +38 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.js +7 -0
- package/dist/server/utils/response.d.ts +44 -0
- package/dist/server/utils/response.js +69 -0
- package/dist/server/utils/sse.d.ts +22 -0
- package/dist/server/utils/sse.js +38 -0
- package/package.json +2 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Session API — stateful Playwright sessions
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/session → create session, returns { sessionId, expiresAt }
|
|
5
|
+
* GET /v1/session/:id → get current page content (Readability text)
|
|
6
|
+
* POST /v1/session/:id/navigate → navigate to URL { url }
|
|
7
|
+
* POST /v1/session/:id/act → execute PageActions array
|
|
8
|
+
* GET /v1/session/:id/screenshot → take screenshot (image/png)
|
|
9
|
+
* DELETE /v1/session/:id → close session
|
|
10
|
+
*
|
|
11
|
+
* Use cases: login flows, multi-step automation, UI testing.
|
|
12
|
+
* This is what Browserbase charges $500/mo for — built into WebPeel.
|
|
13
|
+
*/
|
|
14
|
+
import { Router } from 'express';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { normalizeActions, executeActions } from '../../core/actions.js';
|
|
17
|
+
import { ANTI_DETECTION_ARGS, getRandomViewport, getRandomUserAgent, applyStealthScripts, } from '../../core/browser-pool.js';
|
|
18
|
+
import { extractReadableContent } from '../../core/readability.js';
|
|
19
|
+
const sessions = new Map();
|
|
20
|
+
const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle TTL
|
|
21
|
+
const MAX_SESSIONS_PER_USER = 3; // prevent abuse
|
|
22
|
+
// Cleanup expired sessions every minute
|
|
23
|
+
const _cleanupInterval = setInterval(() => {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const [id, session] of sessions) {
|
|
26
|
+
if (now - session.lastUsedAt > SESSION_TTL_MS) {
|
|
27
|
+
session.browser.close().catch(() => { });
|
|
28
|
+
sessions.delete(id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}, 60_000);
|
|
32
|
+
// Don't keep the Node process alive just for the cleanup timer
|
|
33
|
+
if (_cleanupInterval.unref)
|
|
34
|
+
_cleanupInterval.unref();
|
|
35
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
36
|
+
/** Extract the owner ID from the request — supports both API key and JWT auth. */
|
|
37
|
+
function getOwnerId(req) {
|
|
38
|
+
return req.auth?.keyInfo?.accountId
|
|
39
|
+
|| req.user?.userId
|
|
40
|
+
|| null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Look up a session by id and verify it belongs to the requesting owner.
|
|
44
|
+
* Returns null if not found, expired, or owned by someone else.
|
|
45
|
+
*/
|
|
46
|
+
function getSession(id, ownerId) {
|
|
47
|
+
const session = sessions.get(id);
|
|
48
|
+
if (!session)
|
|
49
|
+
return null;
|
|
50
|
+
if (ownerId && session.ownerId !== ownerId)
|
|
51
|
+
return null; // ownership check
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
/** Launch a fresh Chromium browser for a session (separate instance per session). */
|
|
55
|
+
async function launchBrowser() {
|
|
56
|
+
const { chromium } = await import('playwright');
|
|
57
|
+
const vp = getRandomViewport();
|
|
58
|
+
return chromium.launch({
|
|
59
|
+
headless: true,
|
|
60
|
+
args: [...ANTI_DETECTION_ARGS, `--window-size=${vp.width},${vp.height}`],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Extract readable text from an HTML string using WebPeel's built-in Readability engine. */
|
|
64
|
+
function extractReadableText(html, url) {
|
|
65
|
+
try {
|
|
66
|
+
const result = extractReadableContent(html, url);
|
|
67
|
+
return result.content?.trim() || result.excerpt?.trim() || '';
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ── Router ────────────────────────────────────────────────────────────────────
|
|
74
|
+
export function createSessionRouter() {
|
|
75
|
+
const router = Router();
|
|
76
|
+
// ── POST /v1/session — create session ────────────────────────────────────────
|
|
77
|
+
router.post('/v1/session', async (req, res) => {
|
|
78
|
+
const ownerId = getOwnerId(req);
|
|
79
|
+
if (!ownerId) {
|
|
80
|
+
res.status(401).json({ success: false, error: { type: 'auth_required', message: 'Valid API key or session required.', docs: 'https://webpeel.dev/docs/authentication' }, requestId: req.requestId });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Enforce per-user session cap
|
|
84
|
+
const userSessions = [...sessions.values()].filter(s => s.ownerId === ownerId);
|
|
85
|
+
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
|
86
|
+
res.status(429).json({
|
|
87
|
+
success: false,
|
|
88
|
+
error: {
|
|
89
|
+
type: 'session_limit',
|
|
90
|
+
message: `Maximum ${MAX_SESSIONS_PER_USER} concurrent sessions per user. Delete an existing session first.`,
|
|
91
|
+
hint: 'Delete an existing session via DELETE /v1/session/:id before creating a new one.',
|
|
92
|
+
docs: 'https://webpeel.dev/docs/errors#session-limit',
|
|
93
|
+
},
|
|
94
|
+
requestId: req.requestId || randomUUID(),
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { url } = req.body;
|
|
99
|
+
let browser = null;
|
|
100
|
+
try {
|
|
101
|
+
browser = await launchBrowser();
|
|
102
|
+
const context = await browser.newContext({
|
|
103
|
+
userAgent: getRandomUserAgent(),
|
|
104
|
+
viewport: { width: 1280, height: 800 },
|
|
105
|
+
});
|
|
106
|
+
const page = await context.newPage();
|
|
107
|
+
await applyStealthScripts(page);
|
|
108
|
+
if (url) {
|
|
109
|
+
try {
|
|
110
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
111
|
+
}
|
|
112
|
+
catch (navErr) {
|
|
113
|
+
// Navigation failed — still return the session, caller can retry
|
|
114
|
+
const errMsg = navErr instanceof Error ? navErr.message : String(navErr);
|
|
115
|
+
await browser.close().catch(() => { });
|
|
116
|
+
res.status(502).json({
|
|
117
|
+
success: false,
|
|
118
|
+
error: {
|
|
119
|
+
type: 'navigation_failed',
|
|
120
|
+
message: errMsg,
|
|
121
|
+
hint: 'Check that the URL is accessible and try again.',
|
|
122
|
+
docs: 'https://webpeel.dev/docs/errors#navigation-failed',
|
|
123
|
+
},
|
|
124
|
+
requestId: req.requestId || randomUUID(),
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const id = randomUUID();
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
sessions.set(id, {
|
|
132
|
+
id,
|
|
133
|
+
browser,
|
|
134
|
+
context,
|
|
135
|
+
page,
|
|
136
|
+
ownerId,
|
|
137
|
+
createdAt: now,
|
|
138
|
+
lastUsedAt: now,
|
|
139
|
+
currentUrl: page.url(),
|
|
140
|
+
});
|
|
141
|
+
res.status(201).json({
|
|
142
|
+
sessionId: id,
|
|
143
|
+
currentUrl: page.url(),
|
|
144
|
+
expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (browser)
|
|
149
|
+
await browser.close().catch(() => { });
|
|
150
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
151
|
+
res.status(500).json({
|
|
152
|
+
success: false,
|
|
153
|
+
error: {
|
|
154
|
+
type: 'session_create_failed',
|
|
155
|
+
message: msg,
|
|
156
|
+
docs: 'https://webpeel.dev/docs/errors#session-create-failed',
|
|
157
|
+
},
|
|
158
|
+
requestId: req.requestId || randomUUID(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
// ── GET /v1/session/:id — get page content ───────────────────────────────────
|
|
163
|
+
router.get('/v1/session/:id', async (req, res) => {
|
|
164
|
+
const ownerId = getOwnerId(req);
|
|
165
|
+
const session = getSession(req.params['id'], ownerId);
|
|
166
|
+
if (!session) {
|
|
167
|
+
res.status(404).json({
|
|
168
|
+
success: false,
|
|
169
|
+
error: {
|
|
170
|
+
type: 'session_not_found',
|
|
171
|
+
message: 'Session not found or has expired.',
|
|
172
|
+
hint: 'Create a new session via POST /v1/session.',
|
|
173
|
+
docs: 'https://webpeel.dev/docs/errors#session-not-found',
|
|
174
|
+
},
|
|
175
|
+
requestId: req.requestId || randomUUID(),
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const [html, title] = await Promise.all([
|
|
181
|
+
session.page.content(),
|
|
182
|
+
session.page.title(),
|
|
183
|
+
]);
|
|
184
|
+
const content = await extractReadableText(html, session.page.url());
|
|
185
|
+
session.lastUsedAt = Date.now();
|
|
186
|
+
res.json({
|
|
187
|
+
sessionId: session.id,
|
|
188
|
+
currentUrl: session.page.url(),
|
|
189
|
+
title,
|
|
190
|
+
content,
|
|
191
|
+
expiresAt: new Date(session.lastUsedAt + SESSION_TTL_MS).toISOString(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
196
|
+
res.status(500).json({
|
|
197
|
+
success: false,
|
|
198
|
+
error: {
|
|
199
|
+
type: 'session_error',
|
|
200
|
+
message: msg,
|
|
201
|
+
docs: 'https://webpeel.dev/docs/errors#session-error',
|
|
202
|
+
},
|
|
203
|
+
requestId: req.requestId || randomUUID(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// ── POST /v1/session/:id/navigate ────────────────────────────────────────────
|
|
208
|
+
router.post('/v1/session/:id/navigate', async (req, res) => {
|
|
209
|
+
const ownerId = getOwnerId(req);
|
|
210
|
+
const session = getSession(req.params["id"], ownerId);
|
|
211
|
+
if (!session) {
|
|
212
|
+
res.status(404).json({
|
|
213
|
+
success: false,
|
|
214
|
+
error: {
|
|
215
|
+
type: 'session_not_found',
|
|
216
|
+
message: 'Session not found or has expired.',
|
|
217
|
+
hint: 'Create a new session via POST /v1/session.',
|
|
218
|
+
docs: 'https://webpeel.dev/docs/errors#session-not-found',
|
|
219
|
+
},
|
|
220
|
+
requestId: req.requestId || randomUUID(),
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const { url } = req.body;
|
|
225
|
+
if (!url) {
|
|
226
|
+
res.status(400).json({
|
|
227
|
+
success: false,
|
|
228
|
+
error: {
|
|
229
|
+
type: 'bad_request',
|
|
230
|
+
message: '`url` is required.',
|
|
231
|
+
hint: 'Pass a URL in the request body: { "url": "https://example.com" }',
|
|
232
|
+
docs: 'https://webpeel.dev/docs/errors#bad-request',
|
|
233
|
+
},
|
|
234
|
+
requestId: req.requestId || randomUUID(),
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await session.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
240
|
+
session.lastUsedAt = Date.now();
|
|
241
|
+
session.currentUrl = session.page.url();
|
|
242
|
+
res.json({
|
|
243
|
+
currentUrl: session.page.url(),
|
|
244
|
+
title: await session.page.title(),
|
|
245
|
+
expiresAt: new Date(session.lastUsedAt + SESSION_TTL_MS).toISOString(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
250
|
+
res.status(502).json({
|
|
251
|
+
success: false,
|
|
252
|
+
error: {
|
|
253
|
+
type: 'navigation_failed',
|
|
254
|
+
message: msg,
|
|
255
|
+
hint: 'Check that the URL is accessible and try again.',
|
|
256
|
+
docs: 'https://webpeel.dev/docs/errors#navigation-failed',
|
|
257
|
+
},
|
|
258
|
+
requestId: req.requestId || randomUUID(),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
// ── POST /v1/session/:id/act — execute actions ───────────────────────────────
|
|
263
|
+
router.post('/v1/session/:id/act', async (req, res) => {
|
|
264
|
+
const ownerId = getOwnerId(req);
|
|
265
|
+
const session = getSession(req.params["id"], ownerId);
|
|
266
|
+
if (!session) {
|
|
267
|
+
res.status(404).json({
|
|
268
|
+
success: false,
|
|
269
|
+
error: {
|
|
270
|
+
type: 'session_not_found',
|
|
271
|
+
message: 'Session not found or has expired.',
|
|
272
|
+
hint: 'Create a new session via POST /v1/session.',
|
|
273
|
+
docs: 'https://webpeel.dev/docs/errors#session-not-found',
|
|
274
|
+
},
|
|
275
|
+
requestId: req.requestId || randomUUID(),
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const { actions, screenshot: takeScreenshot } = req.body;
|
|
280
|
+
let normalized;
|
|
281
|
+
try {
|
|
282
|
+
normalized = normalizeActions(actions);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
286
|
+
res.status(400).json({
|
|
287
|
+
success: false,
|
|
288
|
+
error: {
|
|
289
|
+
type: 'invalid_actions',
|
|
290
|
+
message: msg,
|
|
291
|
+
hint: 'Pass a valid actions array: [{ "type": "click", "selector": "#btn" }]',
|
|
292
|
+
docs: 'https://webpeel.dev/docs/errors#invalid-actions',
|
|
293
|
+
},
|
|
294
|
+
requestId: req.requestId || randomUUID(),
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!normalized?.length) {
|
|
299
|
+
res.status(400).json({
|
|
300
|
+
success: false,
|
|
301
|
+
error: {
|
|
302
|
+
type: 'bad_request',
|
|
303
|
+
message: '`actions` must be a non-empty array.',
|
|
304
|
+
hint: 'Pass a valid actions array: [{ "type": "click", "selector": "#btn" }]',
|
|
305
|
+
docs: 'https://webpeel.dev/docs/errors#bad-request',
|
|
306
|
+
},
|
|
307
|
+
requestId: req.requestId || randomUUID(),
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const normalizedActions = normalized;
|
|
312
|
+
try {
|
|
313
|
+
await executeActions(session.page, normalizedActions);
|
|
314
|
+
session.lastUsedAt = Date.now();
|
|
315
|
+
session.currentUrl = session.page.url();
|
|
316
|
+
let screenshot;
|
|
317
|
+
if (takeScreenshot) {
|
|
318
|
+
const buf = await session.page.screenshot({ type: 'png' });
|
|
319
|
+
screenshot = buf.toString('base64');
|
|
320
|
+
}
|
|
321
|
+
const [title, currentUrl] = await Promise.all([
|
|
322
|
+
session.page.title(),
|
|
323
|
+
Promise.resolve(session.page.url()),
|
|
324
|
+
]);
|
|
325
|
+
res.json({
|
|
326
|
+
currentUrl,
|
|
327
|
+
title,
|
|
328
|
+
screenshot,
|
|
329
|
+
actionsExecuted: normalizedActions.length,
|
|
330
|
+
expiresAt: new Date(session.lastUsedAt + SESSION_TTL_MS).toISOString(),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
335
|
+
res.status(502).json({
|
|
336
|
+
success: false,
|
|
337
|
+
error: {
|
|
338
|
+
type: 'action_failed',
|
|
339
|
+
message: msg,
|
|
340
|
+
hint: 'Check your action selectors and ensure the page is loaded.',
|
|
341
|
+
docs: 'https://webpeel.dev/docs/errors#action-failed',
|
|
342
|
+
},
|
|
343
|
+
requestId: req.requestId || randomUUID(),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
// ── GET /v1/session/:id/screenshot ───────────────────────────────────────────
|
|
348
|
+
router.get('/v1/session/:id/screenshot', async (req, res) => {
|
|
349
|
+
const ownerId = getOwnerId(req);
|
|
350
|
+
const session = getSession(req.params["id"], ownerId);
|
|
351
|
+
if (!session) {
|
|
352
|
+
res.status(404).json({
|
|
353
|
+
success: false,
|
|
354
|
+
error: {
|
|
355
|
+
type: 'session_not_found',
|
|
356
|
+
message: 'Session not found or has expired.',
|
|
357
|
+
hint: 'Create a new session via POST /v1/session.',
|
|
358
|
+
docs: 'https://webpeel.dev/docs/errors#session-not-found',
|
|
359
|
+
},
|
|
360
|
+
requestId: req.requestId || randomUUID(),
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const fullPage = req.query.fullPage === 'true';
|
|
366
|
+
const buf = await session.page.screenshot({ type: 'png', fullPage });
|
|
367
|
+
session.lastUsedAt = Date.now();
|
|
368
|
+
res.setHeader('Content-Type', 'image/png');
|
|
369
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
370
|
+
res.send(buf);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
374
|
+
res.status(500).json({
|
|
375
|
+
success: false,
|
|
376
|
+
error: {
|
|
377
|
+
type: 'screenshot_failed',
|
|
378
|
+
message: msg,
|
|
379
|
+
docs: 'https://webpeel.dev/docs/errors#screenshot-failed',
|
|
380
|
+
},
|
|
381
|
+
requestId: req.requestId || randomUUID(),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
// ── DELETE /v1/session/:id ───────────────────────────────────────────────────
|
|
386
|
+
router.delete('/v1/session/:id', async (req, res) => {
|
|
387
|
+
const ownerId = getOwnerId(req);
|
|
388
|
+
const session = getSession(req.params["id"], ownerId);
|
|
389
|
+
if (session) {
|
|
390
|
+
sessions.delete(req.params["id"]);
|
|
391
|
+
await session.browser.close().catch(() => { });
|
|
392
|
+
}
|
|
393
|
+
// Always return 200 (idempotent delete)
|
|
394
|
+
res.json({ closed: true });
|
|
395
|
+
});
|
|
396
|
+
return router;
|
|
397
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats endpoint - provides dashboard statistics
|
|
3
|
+
*/
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { PostgresAuthStore } from '../pg-auth-store.js';
|
|
6
|
+
export function createStatsRouter(authStore) {
|
|
7
|
+
const router = Router();
|
|
8
|
+
router.get('/v1/stats', async (req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
// Require authentication (API key or JWT session token)
|
|
11
|
+
const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
12
|
+
if (!userId) {
|
|
13
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'Authentication required.', docs: 'https://webpeel.dev/docs/authentication' }, requestId: req.requestId });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Only works with PostgreSQL backend
|
|
17
|
+
if (!(authStore instanceof PostgresAuthStore)) {
|
|
18
|
+
res.status(501).json({
|
|
19
|
+
success: false,
|
|
20
|
+
error: {
|
|
21
|
+
type: 'not_implemented',
|
|
22
|
+
message: 'Stats endpoint requires PostgreSQL backend',
|
|
23
|
+
docs: 'https://webpeel.dev/docs/errors#not_implemented',
|
|
24
|
+
},
|
|
25
|
+
requestId: req.requestId,
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Access pool via any cast (pool is private but we need direct DB access)
|
|
30
|
+
const pgStore = authStore;
|
|
31
|
+
// Get stats from usage_logs table
|
|
32
|
+
const statsQuery = `
|
|
33
|
+
SELECT
|
|
34
|
+
COUNT(*) as total_requests,
|
|
35
|
+
AVG(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1.0 ELSE 0.0 END) * 100 as success_rate,
|
|
36
|
+
AVG(processing_time_ms) as avg_response_time
|
|
37
|
+
FROM usage_logs
|
|
38
|
+
WHERE user_id = $1
|
|
39
|
+
`;
|
|
40
|
+
const result = await pgStore.pool.query(statsQuery, [userId]);
|
|
41
|
+
if (result.rows.length === 0) {
|
|
42
|
+
// No data yet - return defaults
|
|
43
|
+
res.json({
|
|
44
|
+
totalRequests: 0,
|
|
45
|
+
successRate: 100,
|
|
46
|
+
avgResponseTime: 0,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const row = result.rows[0];
|
|
51
|
+
res.json({
|
|
52
|
+
totalRequests: parseInt(row.total_requests) || 0,
|
|
53
|
+
successRate: parseFloat(row.success_rate) || 100,
|
|
54
|
+
avgResponseTime: Math.round(parseFloat(row.avg_response_time)) || 0,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error('Stats error:', error);
|
|
59
|
+
res.status(500).json({
|
|
60
|
+
success: false,
|
|
61
|
+
error: {
|
|
62
|
+
type: 'internal_error',
|
|
63
|
+
message: 'Failed to retrieve stats',
|
|
64
|
+
docs: 'https://webpeel.dev/docs/errors#internal_error',
|
|
65
|
+
},
|
|
66
|
+
requestId: req.requestId,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return router;
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe webhook handler for subscription management
|
|
3
|
+
*/
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import pg from 'pg';
|
|
6
|
+
/**
|
|
7
|
+
* Create Stripe Billing Portal router
|
|
8
|
+
* POST /v1/billing/portal — create a Stripe Customer Portal session
|
|
9
|
+
* Requires global auth middleware to already have run (req.user or req.auth set).
|
|
10
|
+
*/
|
|
11
|
+
export declare function createBillingPortalRouter(pool: pg.Pool | null): Router;
|
|
12
|
+
/**
|
|
13
|
+
* Create Stripe webhook router
|
|
14
|
+
*/
|
|
15
|
+
export declare function createStripeRouter(): Router;
|