-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
375 lines (347 loc) · 17.3 KB
/
Copy pathserver.js
File metadata and controls
375 lines (347 loc) · 17.3 KB
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
// Earshot — HTTP server + poll loop in one fork-mode process.
// One process on purpose: the poll timer must never run twice (same lesson as
// the last app on this box). Zero dependencies: node:http + node:sqlite.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
openDb, listProjects, listProjectsForUser, getFeed, saveFeedback, getProject, createProject,
categoryCounts, searchItems,
} from './src/db.js';
import { ask } from './src/ask.js';
import { summarize } from './src/summary.js';
import { CATEGORY_IDS } from './src/themes.js';
import { runOnce } from './src/pipeline.js';
import { tryScan, rateLimited } from './src/try.js';
import {
normalizeEmail, findOrCreateUser, issueLoginLink, redeemLoginLink, createSession,
userForSession, endSession, purgeExpired, parseCookies, sessionCookie, clearedCookie, safeEqual,
setPassword, verifyPassword, hasPassword, checkPasswordStrength, accessFor, setPlan,
} from './src/auth.js';
import { send, loginEmail } from './src/mail.js';
import { latestVisibility } from './src/visibility.js';
const ROOT = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3300);
const POLL_SECONDS = Number(process.env.EARSHOT_POLL_SECONDS || 180);
const TOKEN = process.env.EARSHOT_ADMIN_TOKEN || null;
const db = openDb();
let lastRun = null;
let polling = false;
async function poll() {
if (polling) return; // never overlap a slow pass
polling = true;
try {
const summary = await runOnce(db);
lastRun = { at: new Date().toISOString(), ...summary };
if (summary.errors.length) console.error('poll errors:', summary.errors.join(' | '));
} catch (err) {
console.error('poll failed:', err);
lastRun = { at: new Date().toISOString(), failed: String(err) };
} finally {
polling = false;
}
}
// The admin token stays as the operator's way in and for concierge onboarding;
// customers use sessions.
function isAdmin(req) {
if (!TOKEN) return true; // local/dev mode
const header = req.headers.authorization ?? '';
return safeEqual(header, `Bearer ${TOKEN}`) ||
safeEqual(new URL(req.url, 'http://x').searchParams.get('token'), TOKEN);
}
function currentUser(req) {
return userForSession(db, parseCookies(req.headers.cookie).earshot_session);
}
async function readJson(req, limit = 8000) {
let raw = '';
for await (const chunk of req) {
raw += chunk;
if (raw.length > limit) throw new Error('too much text');
}
return JSON.parse(raw || '{}');
}
const SITE = process.env.EARSHOT_SITE_URL || `http://localhost:${PORT}`;
// nginx passes exactly one hop, so the first entry is the real visitor.
function clientIp(req) {
return (req.headers['x-forwarded-for'] ?? '').split(',')[0].trim() ||
req.socket.remoteAddress || 'unknown';
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
const INDEX_HTML = readFileSync(join(ROOT, 'public/index.html'));
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
try {
if (url.pathname === '/api/status') {
const projects = listProjects(db);
return json(res, 200, {
ok: true, projects: projects.length, lastRun,
sourcesNote: 'open-web only; earshot polls public APIs and stores no reddit data',
});
}
// ── accounts ────────────────────────────────────────────────────────────
// Password sign-up and sign-in complete on the page. The emailed link stays
// as the way back in when a password is forgotten.
if ((url.pathname === '/api/auth/signup' || url.pathname === '/api/auth/signin')
&& req.method === 'POST') {
const signup = url.pathname.endsWith('signup');
if (rateLimited(`pw:${clientIp(req)}`)) {
return json(res, 429, { error: 'too many attempts, wait an hour' });
}
let email, password;
try {
const body = await readJson(req);
email = normalizeEmail(body.email);
password = String(body.password ?? '');
if (signup) checkPasswordStrength(password);
} catch (err) { return json(res, 400, { error: err.message }); }
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (signup) {
if (existing && hasPassword(existing)) {
return json(res, 409, { error: 'an account already exists — sign in instead' });
}
const user = existing ?? findOrCreateUser(db, email);
setPassword(db, user.id, password);
res.writeHead(200, {
'content-type': 'application/json',
'set-cookie': sessionCookie(createSession(db, user.id), { secure: SITE.startsWith('https') }),
});
return res.end(JSON.stringify({ ok: true }));
}
// Identical answer whether the address is unknown or the password is wrong.
if (!existing || !verifyPassword(existing, password)) {
return json(res, 401, { error: 'that email and password do not match' });
}
res.writeHead(200, {
'content-type': 'application/json',
'set-cookie': sessionCookie(createSession(db, existing.id), { secure: SITE.startsWith('https') }),
});
return res.end(JSON.stringify({ ok: true }));
}
if (url.pathname === '/api/auth/link' && req.method === 'POST') {
const ip = clientIp(req);
if (rateLimited(`auth:${ip}`)) return json(res, 429, { error: 'too many sign-in attempts, wait an hour' });
let email;
try { email = normalizeEmail((await readJson(req)).email); }
catch (err) { return json(res, 400, { error: err.message }); }
const user = findOrCreateUser(db, email);
const link = `${SITE}/api/auth/enter?token=${issueLoginLink(db, user.id)}`;
let delivered = false;
try { ({ delivered } = await send({ to: email, ...loginEmail(link) })); }
catch (err) { console.error(`login mail to ${email}: ${err.message}`); }
// Never reveal whether the address is known — the reply is identical either way.
return json(res, 200, { ok: true, delivered });
}
if (url.pathname === '/api/auth/enter' && req.method === 'GET') {
const userId = redeemLoginLink(db, url.searchParams.get('token'));
if (!userId) {
res.writeHead(302, { location: '/app/?expired=1' });
return res.end();
}
purgeExpired(db);
res.writeHead(302, {
location: '/app/',
'set-cookie': sessionCookie(createSession(db, userId), { secure: SITE.startsWith('https') }),
});
return res.end();
}
if (url.pathname === '/api/auth/out' && req.method === 'POST') {
endSession(db, parseCookies(req.headers.cookie).earshot_session);
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearedCookie });
return res.end(JSON.stringify({ ok: true }));
}
if (url.pathname === '/api/me' && req.method === 'GET') {
const user = currentUser(req);
if (!user) return json(res, 401, { error: 'not signed in' });
return json(res, 200, {
email: user.email, plan: user.plan, access: accessFor(user),
billingUrl: process.env.EARSHOT_BILLING_URL || null,
projects: listProjectsForUser(db, user.id).map(p => ({
id: p.id, name: p.name, keywords: p.keywords, threshold: p.score_threshold,
})),
});
}
// Onboarding: a signed-in customer describes what they sell and starts watching.
if (url.pathname === '/api/projects' && req.method === 'POST') {
const user = currentUser(req);
if (!user) return json(res, 401, { error: 'not signed in' });
const access = accessFor(user);
if (!access.active) {
return json(res, 402, { error: access.reason, access });
}
if (listProjectsForUser(db, user.id).length >= 3) {
return json(res, 409, { error: 'one watch list per subscription for now — email support to add more' });
}
let body;
try { body = await readJson(req); } catch (err) { return json(res, 400, { error: err.message }); }
const productDesc = String(body.productDesc ?? '').trim();
const brand = String(body.brand ?? '').trim();
const keywords = (body.keywords ?? []).map(k => String(k).trim()).filter(Boolean).slice(0, 25);
if (!brand) return json(res, 400, { error: 'what name should we watch for?' });
if (productDesc.length < 15) return json(res, 400, { error: 'describe what you sell in a sentence' });
const id = createProject(db, {
userId: user.id,
name: String(body.name ?? '').trim() || brand,
productDesc, brand, keywords,
competitors: (body.competitors ?? []).map(c => String(c).trim()).filter(Boolean).slice(0, 25),
personaExcludes: (body.personaExcludes ?? []).map(c => String(c).trim()).filter(Boolean).slice(0, 10),
alertEmail: user.email,
});
return json(res, 200, { ok: true, projectId: id });
}
if (url.pathname === '/api/visibility' && req.method === 'GET') {
const me = currentUser(req);
const projects = me ? listProjectsForUser(db, me.id) : listProjects(db);
const projectId = Number(url.searchParams.get('project')) || projects[0]?.id;
if (!projectId) return json(res, 404, { error: 'no watch list yet' });
return json(res, 200, { projectId, questions: latestVisibility(db, projectId) });
}
// Payment webhook. Pocketsflow calls this when a subscription starts, renews
// or ends; the shared secret is what makes it trustworthy.
if (url.pathname === '/api/webhooks/pocketsflow' && req.method === 'POST') {
const secret = process.env.POCKETSFLOW_WEBHOOK_SECRET;
const given = req.headers['x-webhook-secret'] || url.searchParams.get('secret');
if (!secret || !safeEqual(given, secret)) return json(res, 401, { error: 'unauthorized' });
let body;
try { body = await readJson(req); } catch { return json(res, 400, { error: 'bad payload' }); }
// Providers disagree on field names; read the ones that plausibly carry it
// rather than guessing one and failing silently on a real payment.
const email = String(body.email || body.customer_email || body.customer?.email || '').trim();
const event = String(body.event || body.type || body.status || '').toLowerCase();
const ref = body.subscription_id || body.subscriptionId || body.id || null;
if (!email) {
console.error('webhook with no email:', JSON.stringify(body).slice(0, 300));
return json(res, 400, { error: 'no email in payload' });
}
const plan = /cancel|refund|expired|ended/.test(event) ? 'cancelled'
: /fail|past_due|unpaid/.test(event) ? 'past_due'
: 'active';
const user = findOrCreateUser(db, normalizeEmail(email));
setPlan(db, user.id, plan, ref);
console.log(`webhook: ${email} → ${plan} (${event || 'no event field'})`);
return json(res, 200, { ok: true, plan });
}
// Public "try it": the landing page's live scan. Open by design, budgeted per IP.
if (url.pathname === '/api/try' && req.method === 'POST') {
const ip = clientIp(req);
if (rateLimited(ip)) {
return json(res, 429, { error: "that's a few scans in an hour — the full radar has no limit" });
}
let raw = '';
for await (const chunk of req) {
raw += chunk;
if (raw.length > 4000) return json(res, 413, { error: 'too much text' });
}
try {
const { keyword, product } = JSON.parse(raw || '{}');
return json(res, 200, await tryScan(keyword, { product }));
} catch (err) {
return json(res, 400, { error: err.message });
}
}
// Public read-only demo: the dogfood project's feed, no token, no writes.
// This is the link that goes in outreach — the product demos itself.
const isDemo = url.searchParams.get('demo') === '1' && req.method === 'GET' &&
['/api/feed', '/api/search'].includes(url.pathname);
const user = currentUser(req);
if (url.pathname.startsWith('/api/') && !isDemo && !user && !isAdmin(req)) {
return json(res, 401, { error: 'unauthorized' });
}
if (url.pathname === '/api/feed' && req.method === 'GET') {
// A signed-in customer only ever sees their own watch lists. The admin
// token still sees everything, which is how concierge onboarding works.
const projects = isDemo ? listProjects(db).slice(0, 1)
: user ? listProjectsForUser(db, user.id)
: listProjects(db);
const requested = Number(url.searchParams.get('project'));
const projectId = isDemo ? projects[0]?.id
: (requested && projects.some(p => p.id === requested) ? requested : projects[0]?.id);
if (!projectId) {
return json(res, 404, { error: 'no watch list yet', needsOnboarding: Boolean(user) });
}
// The demo shows what a customer would actually be alerted about, so it
// is filtered to the alert bar. The dashboard shows near-misses too,
// because that is what you tune against.
const minScore = isDemo ? (getProject(db, projectId)?.score_threshold ?? 65) : 0;
const category = CATEGORY_IDS.includes(url.searchParams.get('category'))
? url.searchParams.get('category') : null;
const sort = ['best', 'hot', 'top', 'new'].includes(url.searchParams.get('sort'))
? url.searchParams.get('sort') : 'best';
return json(res, 200, {
projectId,
demo: isDemo || undefined,
projects: isDemo ? [] : projects.map(p => ({ id: p.id, name: p.name })),
counts: categoryCounts(db, projectId, { minScore }),
items: getFeed(db, projectId, {
limit: isDemo ? 20 : Number(url.searchParams.get('limit')) || 100,
minScore, category, sort,
}),
});
}
// Search over stored conversations. Same visibility rules as the feed.
if (url.pathname === '/api/search' && req.method === 'GET') {
// Demo visitors search the demo project only; owners search their own.
const projects = user ? listProjectsForUser(db, user.id)
: isDemo ? listProjects(db).slice(0, 1) : listProjects(db);
const requested = Number(url.searchParams.get('project'));
const projectId = requested && projects.some(p => p.id === requested)
? requested : projects[0]?.id;
const q = String(url.searchParams.get('q') ?? '').trim();
if (!projectId) return json(res, 404, { error: 'no watch list yet' });
if (q.length < 2) return json(res, 400, { error: 'give me at least two characters' });
return json(res, 200, { projectId, q, items: searchItems(db, projectId, q) });
}
// Ask: a natural-language question over the stored archive.
if (url.pathname === '/api/ask' && req.method === 'POST') {
const projects = user ? listProjectsForUser(db, user.id) : listProjects(db);
let body;
try { body = await readJson(req); } catch (err) { return json(res, 400, { error: err.message }); }
const requested = Number(body.project);
const project = (requested && projects.find(p => p.id === requested)) || projects[0];
if (!project) return json(res, 404, { error: 'no watch list yet' });
const question = String(body.question ?? '').trim();
if (question.length < 5) return json(res, 400, { error: 'ask a real question' });
return json(res, 200, await ask(db, project, question));
}
// Theme analysis for a category (or all).
if (url.pathname === '/api/summary' && req.method === 'GET') {
const projects = user ? listProjectsForUser(db, user.id) : listProjects(db);
const requested = Number(url.searchParams.get('project'));
const project = (requested && projects.find(p => p.id === requested)) || projects[0];
if (!project) return json(res, 404, { error: 'no watch list yet' });
const category = CATEGORY_IDS.includes(url.searchParams.get('category'))
? url.searchParams.get('category') : null;
return json(res, 200, await summarize(db, project, { category }));
}
if (url.pathname === '/api/feedback' && req.method === 'POST') {
let raw = '';
for await (const chunk of req) raw += chunk;
const { itemId, projectId, verdict } = JSON.parse(raw || '{}');
if (!itemId || !projectId || !['good', 'bad'].includes(verdict)) {
return json(res, 400, { error: 'itemId, projectId, verdict(good|bad) required' });
}
// Never let one account tune another account's scoring.
if (user && !listProjectsForUser(db, user.id).some(p => p.id === Number(projectId))) {
return json(res, 403, { error: 'not your watch list' });
}
saveFeedback(db, itemId, projectId, verdict);
return json(res, 200, { ok: true });
}
if (url.pathname === '/' || url.pathname === '/index.html') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(INDEX_HTML);
}
json(res, 404, { error: 'not found' });
} catch (err) {
console.error(`${req.method} ${url.pathname}:`, err);
json(res, 500, { error: 'internal error' });
}
});
server.listen(PORT, () => {
console.log(`earshot listening on :${PORT}, polling every ${POLL_SECONDS}s`);
poll();
setInterval(poll, POLL_SECONDS * 1000);
});