V2 lives entirely under v2/ and is built around three asks the team raised about V1: per-video assets sometimes drifted onto the wrong trend, hashtag scrapes returned junk that wasn't filterable per-client, and there was no multi-user model behind Microsoft SSO. Highlights: - Stable TikTok numeric-id key for every per-video asset; URL form drift is logged loudly to drift_log.jsonl and never silently nulls assets. Stage 5 manifest hard-gates Stage 6 if any selected video is missing any required asset; --drop-failing auto-backfills from the next-best recipe candidates. - Per-brief engagement floor (min_likes / min_plays / min_stl_pct), applied at Apify scrape time and re-validated locally; spend_log.json records raw_returned vs kept_after_floor per scrape. - Users + teams + memberships with owner/admin/editor/viewer roles; SSO upserts a user keyed on Azure oid, auto-creates a personal team, and a super-admin is bootstrapped via BOOTSTRAP_SUPER_ADMIN_EMAIL on first sign-in. Phase A integration test: 16/16 pass. - 10-stage TS pipeline (brief → seed → scrape1 → select → scrape2 → validate → analyse → insights → trends → qa → build) wired through one CLI; each stage idempotent + resumable from disk via .state sentinels. §4.5 rubrics shipped under prompts/ and loaded into Claude calls. - React 18 + Vite + TS + Tailwind operator SPA: brief intake form, team management, super-admin user list, help/FAQ ported from V1. - Separate Docker Compose project (name: social-reporting-v2, port 3457, Postgres 5437) with deploy/setup-v2.sh, deploy-v2.sh, rollback-to-v1.sh scripts that take over V1's /social-reports URL and let us roll back. Verification: 62 unit tests pass (auth/session, ids extractor with full URL fixture, engagement floor, recipes, manifest, linking-fix, MoM compare). Live smoke run on a Dove brief: 1400 raw → 253 kept (82% culled) → 21 fully-bundled videos → 25 editorial trends across 8 brief-driven categories, with drift=0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// Emergency password login. OFF in prod by default. Lifted from V1 dashboard/server.ts:351-386
|
|
// but adapted to V2: still upserts a user row keyed on a deterministic synthetic oid so the
|
|
// same DASH_USER lands in the users table consistently.
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { envBool, envStr } from '../lib/env.js';
|
|
import { parseJSONBody, sendJSON, clientIp } from '../lib/http.js';
|
|
import { issueSession, setSessionCookie } from './session.js';
|
|
import { landSsoUser } from './upsert-user.js';
|
|
|
|
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
const RATE_LIMIT_MAX = 5;
|
|
const attempts = new Map<string, { count: number; firstAt: number }>();
|
|
|
|
function isRateLimited(ip: string): boolean {
|
|
const r = attempts.get(ip);
|
|
if (!r) return false;
|
|
if (Date.now() - r.firstAt > RATE_LIMIT_WINDOW_MS) { attempts.delete(ip); return false; }
|
|
return r.count >= RATE_LIMIT_MAX;
|
|
}
|
|
|
|
function recordAttempt(ip: string): void {
|
|
const now = Date.now();
|
|
const r = attempts.get(ip);
|
|
if (!r || now - r.firstAt > RATE_LIMIT_WINDOW_MS) {
|
|
attempts.set(ip, { count: 1, firstAt: now });
|
|
} else {
|
|
r.count++;
|
|
}
|
|
}
|
|
|
|
export async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
if (!envBool('ALLOW_PASSWORD_FALLBACK', false)) {
|
|
sendJSON(res, 404, { ok: false, error: 'Password login not enabled' });
|
|
return;
|
|
}
|
|
const ip = clientIp(req);
|
|
if (isRateLimited(ip)) {
|
|
sendJSON(res, 429, { ok: false, error: 'Too many attempts. Try again in 15 minutes.' });
|
|
return;
|
|
}
|
|
const DASH_USER = envStr('DASH_USER');
|
|
const DASH_PASS = envStr('DASH_PASS');
|
|
if (!DASH_USER || !DASH_PASS) {
|
|
sendJSON(res, 503, { ok: false, error: 'Password login misconfigured' });
|
|
return;
|
|
}
|
|
|
|
let username = '', password = '';
|
|
try {
|
|
const body = await parseJSONBody<{ username?: string; password?: string }>(req);
|
|
username = body.username ?? '';
|
|
password = body.password ?? '';
|
|
} catch { sendJSON(res, 400, { ok: false, error: 'Invalid body' }); return; }
|
|
|
|
if (username !== DASH_USER || password !== DASH_PASS) {
|
|
recordAttempt(ip);
|
|
sendJSON(res, 401, { ok: false, error: 'Invalid username or password' });
|
|
return;
|
|
}
|
|
attempts.delete(ip);
|
|
|
|
// Land the password user the same way SSO users land — deterministic synthetic oid.
|
|
const oid = `pw:${DASH_USER}`;
|
|
const email = `${DASH_USER}@local.password-fallback`;
|
|
const landed = await landSsoUser({ oid, email, display_name: DASH_USER });
|
|
const token = issueSession({
|
|
user_id: landed.user.id,
|
|
email: landed.user.email,
|
|
active_team_id: landed.active_team_id,
|
|
auth_method: 'password',
|
|
});
|
|
setSessionCookie(res, token);
|
|
sendJSON(res, 200, { ok: true, user: { id: landed.user.id, email: landed.user.email, name: landed.user.display_name } });
|
|
}
|