Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// silo - data & storage dashboard for aesthetic.computer
3
4import "dotenv/config";
5import express from "express";
6import https from "https";
7import http from "http";
8import fs from "fs";
9import path from "path";
10import { fileURLToPath } from "url";
11import { MongoClient, ObjectId } from "mongodb";
12import { createClient } from "redis";
13import { S3Client, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
14import { WebSocketServer } from "ws";
15import {
16 IgApiClient,
17 IgCheckpointError,
18 IgLoginTwoFactorRequiredError,
19 IgLoginBadPasswordError,
20} from "instagram-private-api";
21
22const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
24const app = express();
25const PORT = process.env.PORT || 3003;
26const dev = process.env.NODE_ENV === "development";
27const SERVER_START_TIME = Date.now();
28
29// --- Auth0 ---
30const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || "aesthetic.us.auth0.com";
31const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID || "";
32const AUTH0_CUSTOM_DOMAIN = "hi.aesthetic.computer";
33const ADMIN_SUB = process.env.ADMIN_SUB || "";
34const PUBLISH_SECRET = process.env.PUBLISH_SECRET || "";
35const AUTH_CACHE_TTL = 300_000;
36const authCache = new Map();
37
38// --- Activity Log ---
39const activityLog = [];
40const MAX_LOG = 200;
41let wss = null;
42
43// --- Firehose (MongoDB change stream) ---
44let changeStream = null;
45const firehoseThrottle = { count: 0, resetAt: 0 };
46const FIREHOSE_MAX_PER_SEC = 50;
47
48// Dedup: suppress rapid updates to the same document within a short window
49const FIREHOSE_DEDUP_MS = 2000;
50const firehoseDedup = new Map(); // "coll:docId" → timestamp
51// Clean stale dedup entries every 30s to prevent memory leak
52setInterval(() => {
53 const cutoff = Date.now() - FIREHOSE_DEDUP_MS * 2;
54 for (const [key, ts] of firehoseDedup) {
55 if (ts < cutoff) firehoseDedup.delete(key);
56 }
57}, 30_000);
58
59// --- Instagram (persistent session) ---
60let igClient = null;
61let igLoggedIn = false;
62let igSessionUsername = null;
63let igLastUsed = null;
64let igChallengeInProgress = false;
65
66// --- TikTok (OAuth token-based) ---
67const TIKTOK_CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY || "";
68const TIKTOK_CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET || "";
69const TIKTOK_REDIRECT_URI = process.env.TIKTOK_REDIRECT_URI || "https://silo.aesthetic.computer/api/tiktok/callback";
70let tiktokToken = null; // { access_token, refresh_token, open_id, expires_at, username }
71let tiktokLastUsed = null;
72
73// --- Collection Categories ---
74const COLLECTION_CATEGORIES = {
75 "users": "identity", "@handles": "identity", "verifications": "identity",
76 "paintings": "content", "pieces": "content", "kidlisp": "content",
77 "tapes": "content", "moods": "content",
78 "chat-system": "communication", "chat-clock": "communication", "chat-sotce": "communication",
79 "boots": "system", "kidlisp-logs": "system", "oven-bakes": "system", "_firehose": "system", "insta-sessions": "system", "tiktok-sessions": "system",
80};
81const CATEGORY_META = {
82 identity: { label: "Users & Identity", color: "#48f" },
83 content: { label: "Content", color: "#a6f" },
84 communication: { label: "Communication", color: "#4a4" },
85 system: { label: "System & Logs", color: "#f80" },
86 other: { label: "Other", color: "#888" },
87};
88
89// --- Handle Cache (Auth0 sub → handle) ---
90const handleCache = new Map();
91
92async function loadHandleCache() {
93 if (!db) return;
94 try {
95 const docs = await db.collection("@handles").find({}).toArray();
96 for (const doc of docs) {
97 if (doc._id && doc.handle) handleCache.set(String(doc._id), doc.handle);
98 }
99 log("info", `handle cache loaded: ${handleCache.size} entries`);
100 } catch (err) {
101 log("error", `handle cache load failed: ${err.message}`);
102 }
103}
104
105function resolveHandle(str) {
106 if (!str || typeof str !== "string") return null;
107 if (/^(auth0|google-oauth2|apple|windowslive|github)\|/.test(str)) {
108 return handleCache.get(str) || null;
109 }
110 return str;
111}
112
113function log(type, msg) {
114 const entry = { time: new Date().toISOString(), type, msg };
115 activityLog.unshift(entry);
116 if (activityLog.length > MAX_LOG) activityLog.pop();
117 if (wss?.clients) {
118 const data = JSON.stringify({ logEntry: entry });
119 wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
120 }
121 const prefix = { info: " ", warn: "! ", error: "x " }[type] || " ";
122 console.log(`${prefix}${msg}`);
123}
124
125log("info", "silo starting...");
126
127// Parse a MongoDB URI into a display-friendly host string (no credentials)
128function parseMongoHost(uri) {
129 if (!uri) return "not set";
130 try {
131 const u = new URL(uri.replace("mongodb+srv://", "https://").replace("mongodb://", "http://"));
132 return u.hostname + (u.port ? ":" + u.port : "");
133 } catch { return uri.split("@").pop()?.split("/")[0] || uri; }
134}
135
136// --- MongoDB (primary) ---
137let mongoClient, db;
138
139async function connectMongo() {
140 const uri = process.env.MONGODB_CONNECTION_STRING;
141 if (!uri) { log("error", "MONGODB_CONNECTION_STRING not set"); return; }
142 try {
143 const isLocal = uri.includes("localhost") || uri.includes("127.0.0.1");
144 mongoClient = new MongoClient(uri, {
145 ...(isLocal ? {} : { tls: true }),
146 serverSelectionTimeoutMS: 10000,
147 connectTimeoutMS: 10000,
148 socketTimeoutMS: 45000,
149 maxPoolSize: 5,
150 });
151 await mongoClient.connect();
152 db = mongoClient.db(process.env.MONGODB_NAME || "aesthetic");
153 log("info", `mongo connected (${parseMongoHost(uri)})`);
154 } catch (err) {
155 log("error", `mongo connect failed: ${err.message}`);
156 }
157}
158
159// Extract a short summary from the full document (admin-only dashboard, safe to show)
160function firehoseSummary(coll, op, doc) {
161 if (!doc || op === "delete") return null;
162 // Try to resolve user handle from common fields, resolving Auth0 subs
163 const rawHandle = doc.handle || doc.user;
164 const resolved = rawHandle ? resolveHandle(String(rawHandle)) : null;
165 // Also try resolving the doc _id (often an Auth0 sub in user-related collections)
166 const resolvedId = doc._id ? resolveHandle(String(doc._id)) : null;
167 const who = resolved ? `@${resolved}` : (resolvedId && resolvedId !== String(doc._id) ? `@${resolvedId}` : null);
168 switch (coll) {
169 case "chat-system": case "chat-clock": case "chat-sotce":
170 return (who || "anon") + (doc.text ? ` "${doc.text.slice(0, 30)}"` : "");
171 case "@handles": return who || (doc.handle ? `@${doc.handle}` : null);
172 case "users": {
173 const name = doc.name ? resolveHandle(String(doc.name)) : null;
174 const displayName = name && name !== doc.name ? `@${name}` : doc.name;
175 return doc.email?.split("@")[0] || displayName || who || null;
176 }
177 case "paintings": return (who ? who + " " : "") + (doc.slug || doc.title || "");
178 case "tapes": return (who ? who + " " : "") + (doc.piece || "");
179 case "pieces": return doc.slug || doc.name || null;
180 case "kidlisp": return (who ? who + " " : "") + (doc.code ? "$" + doc.code : "") + (doc.name ? " " + doc.name : "");
181 case "moods": return (who ? who + " " : "") + (doc.mood || "");
182 case "verifications": return who || null;
183 case "boots": {
184 const m = doc.meta || {};
185 const rawUser = m.user?.handle || m.user?.sub || m.user;
186 const bootUser = rawUser ? (resolveHandle(String(rawUser)) || rawUser) : null;
187 const who = bootUser ? `@${bootUser}` : "visitor";
188 const status = doc.status || "";
189 const statusTag = status !== "started" ? ` [${status}]` : "";
190 // Rich info: browser, device, referrer, geo
191 const browser = m.browser || "";
192 const device = m.mobile ? "mobile" : "";
193 const geo = doc.server?.country || "";
194 const referrer = m.referrer ? ` via ${m.referrer.replace(/^https?:\/\//, "").split("/")[0]}` : "";
195 const parts = [who, m.host + (m.path || "/"), browser, device, geo].filter(Boolean);
196 return parts.join(" ") + referrer + statusTag;
197 }
198 case "kidlisp-logs": {
199 const effect = doc.effect || "";
200 const type = doc.type || "";
201 const gpu = doc.device?.gpu?.renderer || "";
202 const ua = doc.device?.mobile ? "mobile" : "desktop";
203 const country = doc.server?.country || "";
204 return [type, effect, gpu, ua, country].filter(Boolean).join(" ");
205 }
206 case "oven-bakes": return doc.status || null;
207 default: {
208 return who || doc.slug || doc.name || doc.email?.split("@")[0] || null;
209 }
210 }
211}
212
213const FIREHOSE_COLLECTION = "_firehose";
214const FIREHOSE_TTL_DAYS = 30;
215
216async function ensureFirehoseCollection() {
217 if (!db) return;
218 try {
219 const col = db.collection(FIREHOSE_COLLECTION);
220 // TTL index: auto-delete events older than 30 days
221 await col.createIndex({ time: 1 }, { expireAfterSeconds: FIREHOSE_TTL_DAYS * 86400 });
222 // Index for recent queries
223 await col.createIndex({ ns: 1, time: -1 });
224 log("info", `_firehose collection ready (${FIREHOSE_TTL_DAYS}d TTL)`);
225 } catch (err) {
226 log("error", `_firehose index setup: ${err.message}`);
227 }
228}
229
230function startFirehose() {
231 if (!db) return;
232 const firehoseCol = db.collection(FIREHOSE_COLLECTION);
233 try {
234 changeStream = db.watch(
235 [{
236 $match: {
237 operationType: { $in: ["insert", "update", "replace", "delete"] },
238 "ns.coll": { $ne: FIREHOSE_COLLECTION }, // avoid infinite loop
239 },
240 }],
241 { fullDocument: "updateLookup" }
242 );
243 log("info", "firehose change stream started");
244
245 changeStream.on("change", (change) => {
246 const now = Date.now();
247 if (now > firehoseThrottle.resetAt) {
248 firehoseThrottle.count = 0;
249 firehoseThrottle.resetAt = now + 1000;
250 }
251 if (firehoseThrottle.count >= FIREHOSE_MAX_PER_SEC) return;
252 firehoseThrottle.count++;
253
254 const coll = change.ns?.coll || "unknown";
255 const op = change.operationType;
256 const docId = change.documentKey?._id?.toString() || null;
257
258 // Keep handle cache fresh when @handles collection changes
259 if (coll === "@handles" && change.fullDocument) {
260 const hDoc = change.fullDocument;
261 if (hDoc._id && hDoc.handle) handleCache.set(String(hDoc._id), hDoc.handle);
262 }
263
264 // Dedup: suppress rapid updates/replaces to the same document
265 // (e.g. boot telemetry writes start→log→complete to same bootId)
266 if (docId && (op === "update" || op === "replace")) {
267 const dedupKey = `${coll}:${docId}`;
268 const prev = firehoseDedup.get(dedupKey);
269 if (prev && now - prev < FIREHOSE_DEDUP_MS) {
270 firehoseDedup.set(dedupKey, now);
271 return; // suppress duplicate
272 }
273 firehoseDedup.set(dedupKey, now);
274 }
275
276 const event = {
277 ns: coll,
278 op,
279 time: new Date(now),
280 docId,
281 summary: firehoseSummary(coll, op, change.fullDocument),
282 };
283
284 // Persist to MongoDB (fire-and-forget)
285 firehoseCol.insertOne(event).catch(() => {});
286
287 if (wss?.clients) {
288 const data = JSON.stringify({ firehose: { ...event, time: now } });
289 wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
290 }
291 });
292
293 changeStream.on("error", (err) => {
294 log("error", `firehose stream error: ${err.message}`);
295 changeStream = null;
296 setTimeout(startFirehose, 5000);
297 });
298 } catch (err) {
299 log("error", `firehose setup failed: ${err.message}`);
300 }
301}
302
303// --- MongoDB Atlas (for comparison) ---
304let atlasClient, atlasDb;
305
306async function connectAtlas() {
307 const uri = process.env.ATLAS_CONNECTION_STRING;
308 if (!uri) { log("info", "ATLAS_CONNECTION_STRING not set, skipping atlas"); return; }
309 // Skip if same as primary
310 if (uri === process.env.MONGODB_CONNECTION_STRING) {
311 log("info", "atlas URI same as primary, sharing connection");
312 atlasClient = mongoClient;
313 atlasDb = db;
314 return;
315 }
316 try {
317 atlasClient = new MongoClient(uri, {
318 tls: true,
319 serverSelectionTimeoutMS: 10000,
320 connectTimeoutMS: 10000,
321 socketTimeoutMS: 45000,
322 maxPoolSize: 3,
323 });
324 await atlasClient.connect();
325 atlasDb = atlasClient.db(process.env.MONGODB_NAME || "aesthetic");
326 log("info", "atlas connected (comparison)");
327 } catch (err) {
328 log("error", `atlas connect failed: ${err.message}`);
329 }
330}
331
332// --- Redis ---
333let redisClient;
334
335async function connectRedis() {
336 const url = process.env.REDIS_CONNECTION_STRING;
337 if (!url) { log("info", "REDIS_CONNECTION_STRING not set, skipping redis"); return; }
338 try {
339 redisClient = createClient({ url });
340 redisClient.on("error", (err) => log("error", `redis error: ${err.message}`));
341 await redisClient.connect();
342 log("info", "redis connected");
343 } catch (err) {
344 log("error", `redis connect failed: ${err.message}`);
345 }
346}
347
348// --- Redis Subscriber (for fairy:point from session server) ---
349let redisSub;
350
351async function connectRedisSub() {
352 const url = process.env.REDIS_CONNECTION_STRING;
353 if (!url) return;
354 try {
355 redisSub = createClient({ url });
356 redisSub.on("error", (err) => log("error", `redis sub error: ${err.message}`));
357 await redisSub.connect();
358
359 await redisSub.subscribe("fairy:point", (message) => {
360 if (wss?.clients) {
361 try {
362 const payload = JSON.stringify({ fairyPoint: JSON.parse(message) });
363 wss.clients.forEach((c) => c.readyState === 1 && c.send(payload));
364 } catch {}
365 }
366 });
367
368 log("info", "redis subscriber connected (fairy:point)");
369 } catch (err) {
370 log("error", `redis sub connect failed: ${err.message}`);
371 }
372}
373
374// --- S3 (DigitalOcean Spaces) ---
375const s3 = new S3Client({
376 endpoint: process.env.SPACES_ENDPOINT,
377 region: "us-east-1",
378 credentials: {
379 accessKeyId: process.env.SPACES_KEY || "",
380 secretAccessKey: process.env.SPACES_SECRET || "",
381 },
382 forcePathStyle: false,
383});
384const BUCKETS = (process.env.BUCKETS || "").split(",").filter(Boolean);
385
386// --- Cached Stats ---
387let cachedDbStats = null, cachedStorageStats = null, cachedAtlasStats = null;
388let dbStatsAge = 0, storageStatsAge = 0, atlasStatsAge = 0;
389const DB_CACHE_TTL = 30_000;
390const STORAGE_CACHE_TTL = 300_000;
391
392async function getCollectionStats(database, label) {
393 if (!database) return null;
394 try {
395 const collections = await database.listCollections().toArray();
396 const stats = [];
397 for (const col of collections) {
398 try {
399 const cs = await database.command({ collStats: col.name });
400 stats.push({
401 name: col.name, count: cs.count || 0,
402 size: cs.size || 0, storageSize: cs.storageSize || 0,
403 indexSize: cs.totalIndexSize || 0,
404 category: COLLECTION_CATEGORIES[col.name] || "other",
405 });
406 } catch {
407 const count = await database.collection(col.name).estimatedDocumentCount();
408 stats.push({
409 name: col.name, count, size: 0, storageSize: 0, indexSize: 0,
410 category: COLLECTION_CATEGORIES[col.name] || "other",
411 });
412 }
413 }
414 stats.sort((a, b) => b.count - a.count);
415 return stats;
416 } catch (err) {
417 log("error", `${label} stats error: ${err.message}`);
418 return null;
419 }
420}
421
422async function getDbStats() {
423 if (cachedDbStats && Date.now() - dbStatsAge < DB_CACHE_TTL) return cachedDbStats;
424 const stats = await getCollectionStats(db, "primary");
425 if (stats) { cachedDbStats = stats; dbStatsAge = Date.now(); }
426 return cachedDbStats;
427}
428
429async function getAtlasStats() {
430 if (cachedAtlasStats && Date.now() - atlasStatsAge < DB_CACHE_TTL) return cachedAtlasStats;
431 const stats = await getCollectionStats(atlasDb, "atlas");
432 if (stats) { cachedAtlasStats = stats; atlasStatsAge = Date.now(); }
433 return cachedAtlasStats;
434}
435
436async function getDbSize(database) {
437 if (!database) return null;
438 try {
439 const stats = await database.stats();
440 return { dataSize: stats.dataSize, storageSize: stats.storageSize, indexSize: stats.indexSize };
441 } catch { return null; }
442}
443
444async function getStorageStats() {
445 if (cachedStorageStats && Date.now() - storageStatsAge < STORAGE_CACHE_TTL) return cachedStorageStats;
446 try {
447 const results = [];
448 for (const bucket of BUCKETS) {
449 let totalSize = 0, totalObjects = 0, continuationToken;
450 do {
451 const resp = await s3.send(new ListObjectsV2Command({
452 Bucket: bucket, ContinuationToken: continuationToken,
453 }));
454 if (resp.Contents) {
455 totalObjects += resp.Contents.length;
456 totalSize += resp.Contents.reduce((sum, obj) => sum + (obj.Size || 0), 0);
457 }
458 continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
459 } while (continuationToken);
460 results.push({ bucket, objects: totalObjects, bytes: totalSize, gb: (totalSize / 1e9).toFixed(2) });
461 }
462 cachedStorageStats = results;
463 storageStatsAge = Date.now();
464 return results;
465 } catch (err) {
466 log("error", `storage stats error: ${err.message}`);
467 return cachedStorageStats || [];
468 }
469}
470
471async function getRedisStats() {
472 if (!redisClient?.isReady) return null;
473 try {
474 const info = await redisClient.info();
475 const parse = (section, key) => {
476 const m = info.match(new RegExp(`${key}:(.+)`));
477 return m ? m[1].trim() : null;
478 };
479 return {
480 connected: true,
481 version: parse("server", "redis_version"),
482 usedMemory: parse("memory", "used_memory_human"),
483 peakMemory: parse("memory", "used_memory_peak_human"),
484 totalKeys: parseInt(parse("keyspace", "keys") || "0") || await redisClient.dbSize(),
485 connectedClients: parseInt(parse("clients", "connected_clients") || "0"),
486 uptimeSeconds: parseInt(parse("server", "uptime_in_seconds") || "0"),
487 hitRate: (() => {
488 const hits = parseInt(parse("stats", "keyspace_hits") || "0");
489 const misses = parseInt(parse("stats", "keyspace_misses") || "0");
490 return hits + misses > 0 ? ((hits / (hits + misses)) * 100).toFixed(1) : "n/a";
491 })(),
492 };
493 } catch (err) {
494 log("error", `redis stats error: ${err.message}`);
495 return { connected: false, error: err.message };
496 }
497}
498
499// --- Auth ---
500async function validateToken(authorization) {
501 if (!authorization) return null;
502 const cached = authCache.get(authorization);
503 if (cached && Date.now() - cached.timestamp < AUTH_CACHE_TTL) return cached;
504 try {
505 const resp = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
506 headers: { Authorization: authorization },
507 });
508 if (!resp.ok) return null;
509 const user = await resp.json();
510 let handle = null, isAdmin = false;
511 if (db) {
512 const doc = await db.collection("@handles").findOne({ _id: user.sub });
513 handle = doc?.handle;
514 isAdmin = !!(user.email_verified && handle === "jeffrey" && user.sub === ADMIN_SUB);
515 }
516 const result = { user, handle, isAdmin, timestamp: Date.now() };
517 authCache.set(authorization, result);
518 if (isAdmin) log("info", `auth: @${handle} verified`);
519 return result;
520 } catch (err) {
521 log("error", `token validation failed: ${err.message}`);
522 return null;
523 }
524}
525
526async function requireAdmin(req, res, next) {
527 // Allow publish secret for CI/CLI scripts
528 if (PUBLISH_SECRET && req.headers["x-publish-secret"] === PUBLISH_SECRET) {
529 req.auth = { handle: "cli", isAdmin: true };
530 return next();
531 }
532 const auth = await validateToken(req.headers.authorization);
533 if (!auth) return res.status(401).json({ error: "Unauthorized" });
534 if (!auth.isAdmin) return res.status(403).json({ error: "Admin access required" });
535 req.auth = auth;
536 next();
537}
538
539// --- Express Routes ---
540app.use(express.json());
541
542app.get("/auth/config", (req, res) => {
543 res.json({ domain: AUTH0_CUSTOM_DOMAIN, clientId: AUTH0_CLIENT_ID });
544});
545
546app.get("/auth/me", async (req, res) => {
547 const auth = await validateToken(req.headers.authorization);
548 if (!auth) return res.status(401).json({ error: "Unauthorized" });
549 res.json({ handle: auth.handle, isAdmin: auth.isAdmin });
550});
551
552// --- Instagram Session Management ---
553async function saveInstaSession(ig, username) {
554 if (!db) return;
555 try {
556 const serialized = await ig.state.serialize();
557 delete serialized.constants;
558 delete serialized.supportedCapabilities;
559 await db.collection("insta-sessions").updateOne(
560 { _id: username },
561 { $set: { _id: username, state: serialized, updatedAt: new Date() } },
562 { upsert: true },
563 );
564 } catch (e) {
565 log("warn", `insta session save failed: ${e.message}`);
566 }
567}
568
569async function getInstaClient() {
570 if (igClient && igLoggedIn) {
571 igLastUsed = Date.now();
572 return igClient;
573 }
574
575 if (!db) throw new Error("Database not connected");
576
577 // Get credentials from MongoDB secrets
578 const secrets = await db.collection("secrets").findOne({ _id: "instagram" });
579 if (!secrets?.username || !secrets?.password) {
580 throw new Error("Instagram credentials not configured in MongoDB secrets");
581 }
582 const username = secrets.username;
583
584 igClient = new IgApiClient();
585 igClient.state.generateDevice(username);
586 igSessionUsername = username;
587
588 // Save session after every Instagram API request
589 igClient.request.end$.subscribe(async () => {
590 await saveInstaSession(igClient, username);
591 });
592
593 // Try to restore session from MongoDB
594 try {
595 const sessionDoc = await db
596 .collection("insta-sessions")
597 .findOne({ _id: username });
598 if (sessionDoc?.state) {
599 await igClient.state.deserialize(sessionDoc.state);
600 await igClient.account.currentUser(); // verify session is alive
601 igLoggedIn = true;
602 igLastUsed = Date.now();
603 log("info", `insta session restored for @${username}`);
604 return igClient;
605 }
606 } catch {
607 log("info", "insta session restore failed, need fresh login via dashboard");
608 }
609
610 throw new Error(
611 "Instagram session not available. Log in via silo dashboard.",
612 );
613}
614
615function formatInstaCount(n) {
616 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "m";
617 if (n >= 1000) return (n / 1000).toFixed(1) + "k";
618 return String(n);
619}
620
621// --- Instagram Public Routes (CORS-enabled, no auth) ---
622app.use("/insta", (req, res, next) => {
623 res.header("Access-Control-Allow-Origin", "*");
624 res.header("Access-Control-Allow-Methods", "GET, OPTIONS");
625 res.header("Access-Control-Allow-Headers", "Content-Type");
626 if (req.method === "OPTIONS") return res.sendStatus(200);
627 next();
628});
629
630app.get("/insta", async (req, res) => {
631 const { action, username, max } = req.query;
632
633 if (!action || !username) {
634 return res.status(400).json({ error: "action and username required" });
635 }
636
637 const cleanUsername = username.replace(/^@/, "").toLowerCase();
638
639 try {
640 const ig = await getInstaClient();
641
642 if (action === "profile") {
643 const userId = await ig.user.getIdByUsername(cleanUsername);
644 const info = await ig.user.info(userId);
645 return res.json({
646 username: info.username,
647 fullName: info.full_name || "",
648 bio: info.biography || "",
649 profilePicUrl: info.profile_pic_url,
650 mediaCount: info.media_count,
651 followerCount: info.follower_count,
652 followingCount: info.following_count,
653 isVerified: info.is_verified,
654 isPrivate: info.is_private,
655 externalUrl: info.external_url || null,
656 mediaCountFormatted: formatInstaCount(info.media_count),
657 followerCountFormatted: formatInstaCount(info.follower_count),
658 followingCountFormatted: formatInstaCount(info.following_count),
659 });
660 }
661
662 if (action === "feed") {
663 const maxPosts = parseInt(max || "18", 10);
664 const userId = await ig.user.getIdByUsername(cleanUsername);
665 const feed = ig.feed.user(userId);
666 const items = await feed.items();
667 const posts = items.slice(0, maxPosts).map((item) => ({
668 id: item.id,
669 shortcode: item.code,
670 mediaType: item.media_type,
671 caption: item.caption?.text || "",
672 likeCount: item.like_count || 0,
673 commentCount: item.comment_count || 0,
674 timestamp: item.taken_at,
675 width: item.original_width,
676 height: item.original_height,
677 thumbnailUrl:
678 item.image_versions2?.candidates?.[
679 item.image_versions2.candidates.length - 1
680 ]?.url || null,
681 likeCountFormatted: formatInstaCount(item.like_count || 0),
682 commentCountFormatted: formatInstaCount(item.comment_count || 0),
683 }));
684 return res.json({ posts, hasMore: feed.isMoreAvailable() });
685 }
686
687 return res.status(400).json({ error: `Unknown action: ${action}` });
688 } catch (err) {
689 log("error", `insta public API error: ${err.message}`);
690
691 if (err.message.includes("not configured") || err.message.includes("not available")) {
692 return res.status(503).json({ error: err.message });
693 }
694 if (err.message.includes("User not found") || err.name === "IgExactUserNotFoundError") {
695 return res.status(404).json({ error: `User @${cleanUsername} not found` });
696 }
697
698 // Session may have expired
699 igLoggedIn = false;
700 igClient = null;
701 return res.status(500).json({ error: "Instagram API error" });
702 }
703});
704
705// --- TikTok Session Management ---
706async function saveTiktokSession(tokenData) {
707 if (!db) return;
708 try {
709 await db.collection("tiktok-sessions").updateOne(
710 { _id: "default" },
711 { $set: { ...tokenData, updatedAt: new Date() } },
712 { upsert: true },
713 );
714 } catch (e) {
715 log("warn", `tiktok session save failed: ${e.message}`);
716 }
717}
718
719async function loadTiktokSession() {
720 if (tiktokToken) return tiktokToken;
721 if (!db) return null;
722 try {
723 const doc = await db.collection("tiktok-sessions").findOne({ _id: "default" });
724 if (doc?.access_token) {
725 tiktokToken = doc;
726 log("info", `tiktok session restored for @${doc.username || doc.open_id}`);
727 return tiktokToken;
728 }
729 } catch {
730 log("info", "tiktok session restore failed");
731 }
732 return null;
733}
734
735async function refreshTiktokToken() {
736 if (!tiktokToken?.refresh_token) return false;
737 try {
738 const resp = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
739 method: "POST",
740 headers: { "Content-Type": "application/x-www-form-urlencoded" },
741 body: new URLSearchParams({
742 client_key: TIKTOK_CLIENT_KEY,
743 client_secret: TIKTOK_CLIENT_SECRET,
744 grant_type: "refresh_token",
745 refresh_token: tiktokToken.refresh_token,
746 }),
747 });
748 const data = await resp.json();
749 if (data.access_token) {
750 tiktokToken.access_token = data.access_token;
751 tiktokToken.expires_at = Date.now() + data.expires_in * 1000;
752 if (data.refresh_token) tiktokToken.refresh_token = data.refresh_token;
753 await saveTiktokSession(tiktokToken);
754 log("info", "tiktok token refreshed");
755 return true;
756 }
757 log("warn", `tiktok refresh failed: ${JSON.stringify(data)}`);
758 return false;
759 } catch (e) {
760 log("error", `tiktok refresh error: ${e.message}`);
761 return false;
762 }
763}
764
765async function tiktokApiFetch(endpoint, fields) {
766 let token = await loadTiktokSession();
767 if (!token) throw new Error("TikTok not connected. Authorize via silo dashboard.");
768
769 // Refresh if expired or about to expire (5 min buffer)
770 if (token.expires_at && Date.now() > token.expires_at - 300_000) {
771 const ok = await refreshTiktokToken();
772 if (!ok) {
773 tiktokToken = null;
774 throw new Error("TikTok token expired and refresh failed. Re-authorize via dashboard.");
775 }
776 token = tiktokToken;
777 }
778
779 const url = `https://open.tiktokapis.com/v2/${endpoint}${fields ? "?fields=" + fields : ""}`;
780 const resp = await fetch(url, {
781 headers: { Authorization: `Bearer ${token.access_token}` },
782 });
783 const data = await resp.json();
784 if (data.error?.code === "access_token_invalid") {
785 // Try one refresh
786 const ok = await refreshTiktokToken();
787 if (ok) {
788 const retry = await fetch(url, {
789 headers: { Authorization: `Bearer ${tiktokToken.access_token}` },
790 });
791 return retry.json();
792 }
793 tiktokToken = null;
794 throw new Error("TikTok token invalid. Re-authorize via dashboard.");
795 }
796 tiktokLastUsed = Date.now();
797 return data;
798}
799
800function formatCount(n) {
801 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "m";
802 if (n >= 1000) return (n / 1000).toFixed(1) + "k";
803 return String(n);
804}
805
806// --- TikTok Public Routes (CORS-enabled, no auth) ---
807app.use("/tiktok", (req, res, next) => {
808 res.header("Access-Control-Allow-Origin", "*");
809 res.header("Access-Control-Allow-Methods", "GET, OPTIONS");
810 res.header("Access-Control-Allow-Headers", "Content-Type");
811 if (req.method === "OPTIONS") return res.sendStatus(200);
812 next();
813});
814
815app.get("/tiktok", async (req, res) => {
816 const { action } = req.query;
817 if (!action) return res.status(400).json({ error: "action required (profile, videos)" });
818
819 try {
820 if (action === "profile") {
821 const data = await tiktokApiFetch(
822 "user/info/",
823 "open_id,display_name,avatar_url,bio_description,follower_count,following_count,likes_count,video_count,is_verified,username,profile_deep_link",
824 );
825 const u = data.data?.user || {};
826 return res.json({
827 username: u.username || u.display_name,
828 displayName: u.display_name,
829 bio: u.bio_description || "",
830 avatarUrl: u.avatar_url,
831 followerCount: u.follower_count,
832 followingCount: u.following_count,
833 likesCount: u.likes_count,
834 videoCount: u.video_count,
835 isVerified: u.is_verified,
836 profileUrl: u.profile_deep_link,
837 followerCountFormatted: formatCount(u.follower_count || 0),
838 followingCountFormatted: formatCount(u.following_count || 0),
839 likesCountFormatted: formatCount(u.likes_count || 0),
840 videoCountFormatted: formatCount(u.video_count || 0),
841 });
842 }
843
844 if (action === "videos") {
845 const max = parseInt(req.query.max || "20", 10);
846 const data = await tiktokApiFetch(
847 "video/list/",
848 "id,title,cover_image_url,share_url,view_count,like_count,comment_count,share_count,create_time,duration",
849 );
850 const videos = (data.data?.videos || []).slice(0, max).map((v) => ({
851 id: v.id,
852 title: v.title || "",
853 coverUrl: v.cover_image_url,
854 shareUrl: v.share_url,
855 viewCount: v.view_count,
856 likeCount: v.like_count,
857 commentCount: v.comment_count,
858 shareCount: v.share_count,
859 createTime: v.create_time,
860 duration: v.duration,
861 viewCountFormatted: formatCount(v.view_count || 0),
862 likeCountFormatted: formatCount(v.like_count || 0),
863 }));
864 return res.json({ videos, cursor: data.data?.cursor, hasMore: data.data?.has_more });
865 }
866
867 return res.status(400).json({ error: `Unknown action: ${action}` });
868 } catch (err) {
869 log("error", `tiktok public API error: ${err.message}`);
870 if (err.message.includes("not connected") || err.message.includes("Re-authorize")) {
871 return res.status(503).json({ error: err.message });
872 }
873 return res.status(500).json({ error: "TikTok API error" });
874 }
875});
876
877// --- TikTok OAuth Callback (must be before requireAdmin) ---
878app.get("/api/tiktok/callback", async (req, res) => {
879 const { code, error: oauthError } = req.query;
880 if (oauthError || !code) {
881 return res.send(`<html><body><h2>TikTok auth failed</h2><p>${oauthError || "no code"}</p><script>setTimeout(()=>window.close(),2000)</script></body></html>`);
882 }
883
884 try {
885 const resp = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
886 method: "POST",
887 headers: { "Content-Type": "application/x-www-form-urlencoded" },
888 body: new URLSearchParams({
889 client_key: TIKTOK_CLIENT_KEY,
890 client_secret: TIKTOK_CLIENT_SECRET,
891 code,
892 grant_type: "authorization_code",
893 redirect_uri: TIKTOK_REDIRECT_URI,
894 }),
895 });
896 const data = await resp.json();
897 if (!data.access_token) {
898 log("error", `tiktok oauth token exchange failed: ${JSON.stringify(data)}`);
899 return res.send(`<html><body><h2>Token exchange failed</h2><pre>${JSON.stringify(data, null, 2)}</pre></body></html>`);
900 }
901
902 // Fetch username
903 let username = "";
904 try {
905 const userResp = await fetch("https://open.tiktokapis.com/v2/user/info/?fields=username,display_name", {
906 headers: { Authorization: `Bearer ${data.access_token}` },
907 });
908 const userData = await userResp.json();
909 username = userData.data?.user?.username || userData.data?.user?.display_name || "";
910 } catch {}
911
912 tiktokToken = {
913 access_token: data.access_token,
914 refresh_token: data.refresh_token,
915 open_id: data.open_id,
916 expires_at: Date.now() + data.expires_in * 1000,
917 refresh_expires_at: data.refresh_expires_in ? Date.now() + data.refresh_expires_in * 1000 : null,
918 scope: data.scope,
919 username,
920 };
921 tiktokLastUsed = Date.now();
922 await saveTiktokSession(tiktokToken);
923 log("info", `tiktok connected for @${username || data.open_id}`);
924
925 return res.send(`<html><body style="font-family:monospace;background:#111;color:#6c6;display:flex;align-items:center;justify-content:center;height:100vh"><h2>tiktok connected ✓</h2><script>setTimeout(()=>window.close(),1500)</script></body></html>`);
926 } catch (err) {
927 log("error", `tiktok callback error: ${err.message}`);
928 return res.send(`<html><body><h2>Error</h2><p>${err.message}</p></body></html>`);
929 }
930});
931
932app.use("/api", requireAdmin);
933
934app.get("/health", async (req, res) => {
935 const mongoOk = db ? await db.admin().ping().then(() => true).catch(() => false) : false;
936 res.json({ status: "ok", uptime: Date.now() - SERVER_START_TIME, mongo: mongoOk });
937});
938
939app.get("/api/overview", async (req, res) => {
940 log("info", "overview requested");
941 const [collections, storage, redis, dbSize] = await Promise.all([
942 getDbStats(), getStorageStats(), getRedisStats(), getDbSize(db),
943 ]);
944 const find = (name) => collections?.find((c) => c.name === name)?.count || 0;
945 res.json({
946 db: {
947 users: find("users"), handles: find("@handles"), paintings: find("paintings"),
948 pieces: find("pieces"), kidlisp: find("kidlisp"), moods: find("moods"),
949 chatSystem: find("chat-system"), chatClock: find("chat-clock"),
950 verifications: find("verifications"),
951 totalCollections: collections?.length || 0,
952 totalDocuments: collections?.reduce((s, c) => s + c.count, 0) || 0,
953 size: dbSize,
954 },
955 storage: {
956 buckets: storage,
957 totalGB: storage.reduce((s, b) => s + parseFloat(b.gb), 0).toFixed(2),
958 },
959 redis,
960 uptime: Date.now() - SERVER_START_TIME,
961 });
962});
963
964// 📡 Telemetry overview — GPU/KidLisp logs + boot logs stats
965app.get("/api/telemetry", async (req, res) => {
966 if (!db) return res.status(503).json({ error: "no db" });
967 try {
968 const [klStats, bootStats, klRecent] = await Promise.all([
969 db.collection("kidlisp-logs").aggregate([
970 { $facet: {
971 total: [{ $count: "n" }],
972 byType: [{ $group: { _id: "$type", count: { $sum: 1 } } }],
973 byEffect: [{ $group: { _id: "$effect", count: { $sum: 1 } } }],
974 byGpu: [{ $group: { _id: "$device.gpu.renderer", count: { $sum: 1 } } }],
975 size: [{ $group: { _id: null, avgSize: { $avg: { $bsonSize: "$$ROOT" } } } }],
976 }}
977 ]).toArray(),
978 db.collection("boots").aggregate([
979 { $facet: {
980 total: [{ $count: "n" }],
981 byStatus: [{ $group: { _id: "$status", count: { $sum: 1 } } }],
982 }}
983 ]).toArray(),
984 db.collection("kidlisp-logs")
985 .find()
986 .sort({ createdAt: -1 })
987 .limit(20)
988 .toArray(),
989 ]);
990
991 const kl = klStats[0] || {};
992 const bt = bootStats[0] || {};
993 const klTotal = kl.total?.[0]?.n || 0;
994 const klAvgSize = kl.size?.[0]?.avgSize || 0;
995
996 res.json({
997 kidlispLogs: {
998 total: klTotal,
999 estimatedSizeKB: Math.round((klTotal * klAvgSize) / 1024),
1000 byType: kl.byType || [],
1001 byEffect: kl.byEffect || [],
1002 byGpu: kl.byGpu || [],
1003 recent: klRecent,
1004 },
1005 boots: {
1006 total: bt.total?.[0]?.n || 0,
1007 byStatus: bt.byStatus || [],
1008 },
1009 });
1010 } catch (err) {
1011 res.status(500).json({ error: err.message });
1012 }
1013});
1014
1015// Purge kidlisp-logs from silo
1016app.delete("/api/telemetry/kidlisp-logs", async (req, res) => {
1017 if (!db) return res.status(503).json({ error: "no db" });
1018 try {
1019 const result = await db.collection("kidlisp-logs").deleteMany({});
1020 log("info", `purged ${result.deletedCount} kidlisp-logs`);
1021 res.json({ ok: true, deleted: result.deletedCount });
1022 } catch (err) {
1023 res.status(500).json({ error: err.message });
1024 }
1025});
1026
1027app.get("/api/db/collections", async (req, res) => {
1028 log("info", "collections requested");
1029 const stats = await getDbStats() || [];
1030 res.json({ collections: stats, categories: CATEGORY_META });
1031});
1032
1033app.get("/api/db/backups", async (req, res) => {
1034 log("info", "backups requested");
1035 const backups = [];
1036 // Check local filesystem for mongodump backups
1037 const backupPath = process.env.BACKUP_PATH || "/var/backups/mongodb";
1038 try {
1039 if (fs.existsSync(backupPath)) {
1040 const entries = fs.readdirSync(backupPath, { withFileTypes: true });
1041 for (const entry of entries) {
1042 try {
1043 const fullPath = backupPath + "/" + entry.name;
1044 const stat = fs.statSync(fullPath);
1045 let totalSize = stat.size;
1046 // For directories (mongodump output), sum up contents
1047 if (entry.isDirectory()) {
1048 totalSize = 0;
1049 const walk = (dir) => {
1050 for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
1051 const fp = dir + "/" + f.name;
1052 if (f.isDirectory()) walk(fp);
1053 else totalSize += fs.statSync(fp).size;
1054 }
1055 };
1056 walk(fullPath);
1057 }
1058 backups.push({
1059 name: entry.name, source: "local", size: totalSize,
1060 date: stat.mtime.toISOString(), isDirectory: entry.isDirectory(),
1061 });
1062 } catch {}
1063 }
1064 }
1065 } catch (err) {
1066 log("error", `backup scan (local): ${err.message}`);
1067 }
1068 // Check S3 for backups
1069 const backupBucket = process.env.BACKUP_BUCKET;
1070 const backupPrefix = process.env.BACKUP_PREFIX || "backups/";
1071 if (backupBucket) {
1072 try {
1073 let continuationToken;
1074 do {
1075 const resp = await s3.send(new ListObjectsV2Command({
1076 Bucket: backupBucket, Prefix: backupPrefix,
1077 ContinuationToken: continuationToken,
1078 }));
1079 if (resp.Contents) {
1080 for (const obj of resp.Contents) {
1081 const name = obj.Key.replace(backupPrefix, "");
1082 if (!name) continue;
1083 backups.push({
1084 name, source: "s3", bucket: backupBucket,
1085 size: obj.Size, date: obj.LastModified?.toISOString(),
1086 });
1087 }
1088 }
1089 continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
1090 } while (continuationToken);
1091 } catch (err) {
1092 log("error", `backup scan (s3): ${err.message}`);
1093 }
1094 }
1095 backups.sort((a, b) => new Date(b.date) - new Date(a.date));
1096 res.json({ backups, backupPath, backupBucket: backupBucket || null });
1097});
1098
1099app.get("/api/db/compare", async (req, res) => {
1100 log("info", "db compare requested");
1101 const [primary, atlas, primarySize, atlasSize] = await Promise.all([
1102 getDbStats(), getAtlasStats(), getDbSize(db), getDbSize(atlasDb),
1103 ]);
1104 const primaryHost = parseMongoHost(process.env.MONGODB_CONNECTION_STRING);
1105 const atlasHost = parseMongoHost(process.env.ATLAS_CONNECTION_STRING);
1106
1107 // Silo is now the source of truth; Atlas is deprecated
1108 const activeDb = "silo";
1109
1110 const allNames = new Set([
1111 ...(primary || []).map(c => c.name),
1112 ...(atlas || []).map(c => c.name),
1113 ]);
1114 const comparison = [...allNames].sort().map(name => {
1115 const p = primary?.find(c => c.name === name);
1116 const a = atlas?.find(c => c.name === name);
1117 return {
1118 name,
1119 primary: p?.count ?? null,
1120 atlas: a?.count ?? null,
1121 synced: p?.count === a?.count,
1122 };
1123 });
1124 const allSynced = comparison.every(c => c.synced);
1125
1126 res.json({
1127 primaryLabel: primaryHost, atlasLabel: atlasHost,
1128 activeDb, allSynced,
1129 primarySize, atlasSize,
1130 primaryConnected: !!db,
1131 atlasConnected: !!atlasDb,
1132 sameConnection: process.env.MONGODB_CONNECTION_STRING === process.env.ATLAS_CONNECTION_STRING,
1133 collections: comparison,
1134 });
1135});
1136
1137// Sync: copy all collections from Atlas → primary
1138let syncInProgress = false;
1139app.post("/api/db/sync", async (req, res) => {
1140 if (syncInProgress) return res.status(409).json({ error: "Sync already in progress" });
1141 if (!db || !atlasDb) return res.status(500).json({ error: "Both databases must be connected" });
1142 if (atlasDb === db) return res.status(400).json({ error: "Primary and Atlas are the same connection" });
1143
1144 syncInProgress = true;
1145 log("info", "sync started: atlas -> primary");
1146
1147 try {
1148 const collections = await atlasDb.listCollections().toArray();
1149 const results = [];
1150 for (const col of collections) {
1151 const name = col.name;
1152 const docs = await atlasDb.collection(name).find({}).toArray();
1153 // Drop and re-insert
1154 await db.collection(name).deleteMany({});
1155 if (docs.length > 0) {
1156 await db.collection(name).insertMany(docs);
1157 }
1158 results.push({ name, docs: docs.length });
1159 log("info", `synced ${name}: ${docs.length} docs`);
1160 }
1161 // Invalidate cache
1162 cachedDbStats = null;
1163 dbStatsAge = 0;
1164 log("info", `sync complete: ${results.length} collections`);
1165 res.json({ ok: true, collections: results });
1166 } catch (err) {
1167 log("error", `sync failed: ${err.message}`);
1168 res.status(500).json({ error: err.message });
1169 } finally {
1170 syncInProgress = false;
1171 }
1172});
1173
1174app.get("/api/db/health", async (req, res) => {
1175 if (!db) return res.json({ connected: false });
1176 try {
1177 const status = await db.admin().serverStatus();
1178 res.json({
1179 connected: true, version: status.version, uptime: status.uptime,
1180 connections: status.connections, opcounters: status.opcounters,
1181 });
1182 } catch (err) { res.json({ connected: false, error: err.message }); }
1183});
1184
1185app.get("/api/redis", async (req, res) => {
1186 log("info", "redis stats requested");
1187 res.json(await getRedisStats() || { connected: false });
1188});
1189
1190app.get("/api/storage/buckets", async (req, res) => {
1191 log("info", "storage buckets requested");
1192 res.json(await getStorageStats());
1193});
1194
1195app.get("/api/services/oven", async (req, res) => {
1196 const url = process.env.OVEN_URL || "https://localhost:3002";
1197 try {
1198 const start = Date.now();
1199 const resp = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
1200 const data = await resp.json();
1201 res.json({ status: "ok", responseMs: Date.now() - start, ...data });
1202 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1203});
1204
1205app.get("/api/services/session", async (req, res) => {
1206 const url = process.env.SESSION_URL || "https://localhost:8889";
1207 try {
1208 const start = Date.now();
1209 const resp = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
1210 const data = await resp.json();
1211 res.json({ status: "ok", responseMs: Date.now() - start, ...data });
1212 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1213});
1214
1215app.get("/api/services/feed", async (req, res) => {
1216 const url = process.env.FEED_URL || "http://localhost:8787";
1217 try {
1218 const start = Date.now();
1219 const resp = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(5000) });
1220 const data = await resp.json();
1221 res.json({ status: "ok", responseMs: Date.now() - start, ...data });
1222 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1223});
1224
1225app.get("/api/services/feed/info", async (req, res) => {
1226 const url = process.env.FEED_URL || "http://localhost:8787";
1227 try {
1228 const start = Date.now();
1229 const resp = await fetch(`${url}/api/v1`, { signal: AbortSignal.timeout(5000) });
1230 const data = await resp.json();
1231 res.json({ status: "ok", responseMs: Date.now() - start, ...data });
1232 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1233});
1234
1235app.get("/api/services/feed/playlists", async (req, res) => {
1236 const url = process.env.FEED_URL || "http://localhost:8787";
1237 const apiSecret = process.env.FEED_API_SECRET || "";
1238 try {
1239 const start = Date.now();
1240 const headers = apiSecret ? { Authorization: `Bearer ${apiSecret}` } : {};
1241 const resp = await fetch(`${url}/api/v1/playlists?limit=100`, {
1242 headers,
1243 signal: AbortSignal.timeout(10000),
1244 });
1245 const data = await resp.json();
1246 res.json({ status: "ok", responseMs: Date.now() - start, ...data });
1247 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1248});
1249
1250app.get("/api/services/feed/channels", async (req, res) => {
1251 const url = process.env.FEED_URL || "http://localhost:8787";
1252 const apiSecret = process.env.FEED_API_SECRET || "";
1253 try {
1254 const start = Date.now();
1255 const headers = apiSecret ? { Authorization: `Bearer ${apiSecret}` } : {};
1256 const resp = await fetch(`${url}/api/v1/channels?limit=100`, {
1257 headers,
1258 signal: AbortSignal.timeout(10000),
1259 });
1260 const data = await resp.json();
1261 res.json({ status: "ok", responseMs: Date.now() - start, ...data });
1262 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1263});
1264
1265// --- Lith stats proxy ---
1266const LITH_URL = process.env.LITH_URL || "https://aesthetic.computer";
1267
1268app.get("/api/services/lith/stats", async (req, res) => {
1269 try {
1270 const resp = await fetch(`${LITH_URL}/lith/stats`, { signal: AbortSignal.timeout(5000) });
1271 res.json(await resp.json());
1272 } catch (err) { res.json({ status: "unreachable", error: err.message }); }
1273});
1274
1275app.get("/api/services/lith/errors", async (req, res) => {
1276 try {
1277 const resp = await fetch(`${LITH_URL}/lith/errors?limit=${req.query.limit || 100}`, { signal: AbortSignal.timeout(5000) });
1278 res.json(await resp.json());
1279 } catch (err) { res.json({ errors: [], error: err.message }); }
1280});
1281
1282app.get("/api/services/lith/requests", async (req, res) => {
1283 try {
1284 const resp = await fetch(`${LITH_URL}/lith/requests?limit=${req.query.limit || 100}`, { signal: AbortSignal.timeout(5000) });
1285 res.json(await resp.json());
1286 } catch (err) { res.json({ requests: [], error: err.message }); }
1287});
1288
1289app.get("/api/services/billing", async (req, res) => {
1290 const billingUrl = process.env.BILLING_URL || "https://aesthetic.computer/api/billing";
1291 try {
1292 const start = Date.now();
1293 const authHeader = req.headers.authorization || "";
1294 const resp = await fetch(billingUrl, {
1295 headers: authHeader ? { Authorization: authHeader } : {},
1296 signal: AbortSignal.timeout(10000),
1297 });
1298
1299 let data = null;
1300 try {
1301 data = await resp.json();
1302 } catch {
1303 data = null;
1304 }
1305
1306 if (!resp.ok) {
1307 return res.status(resp.status).json({
1308 status: "error",
1309 responseMs: Date.now() - start,
1310 error: data?.error || `Billing API error (${resp.status})`,
1311 });
1312 }
1313
1314 return res.json({
1315 status: "ok",
1316 responseMs: Date.now() - start,
1317 ...data,
1318 });
1319 } catch (err) {
1320 return res.json({ status: "unreachable", error: err.message });
1321 }
1322});
1323
1324app.get("/api/firehose/history", async (req, res) => {
1325 if (!db) return res.json([]);
1326 const limit = Math.min(parseInt(req.query.limit) || 100, 1000);
1327 const ns = req.query.ns || null;
1328 const query = ns ? { ns } : {};
1329 try {
1330 const events = await db.collection(FIREHOSE_COLLECTION)
1331 .find(query).sort({ time: -1 }).limit(limit).toArray();
1332 res.json(events.reverse());
1333 } catch (err) {
1334 res.status(500).json({ error: err.message });
1335 }
1336});
1337
1338app.get("/api/firehose/stats", async (req, res) => {
1339 if (!db) return res.json({});
1340 try {
1341 const col = db.collection(FIREHOSE_COLLECTION);
1342 const total = await col.estimatedDocumentCount();
1343 const pipeline = [
1344 { $group: { _id: { ns: "$ns", op: "$op" }, count: { $sum: 1 } } },
1345 { $sort: { count: -1 } },
1346 ];
1347 const breakdown = await col.aggregate(pipeline).toArray();
1348 res.json({ total, breakdown });
1349 } catch (err) {
1350 res.status(500).json({ error: err.message });
1351 }
1352});
1353
1354// --- Database Browser ---
1355app.get("/api/db/browse/:collection", async (req, res) => {
1356 if (!db) return res.status(503).json({ error: "Database not connected" });
1357 const colName = req.params.collection;
1358 const skip = Math.max(0, parseInt(req.query.skip) || 0);
1359 const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 25));
1360 const sortField = req.query.sort || "_id";
1361 const sortDir = req.query.dir === "asc" ? 1 : -1;
1362 const q = req.query.q || "";
1363
1364 try {
1365 const col = db.collection(colName);
1366 let filter = {};
1367 if (q) {
1368 // Search by _id (string or ObjectId) or text match on common fields
1369 const conditions = [
1370 { _id: q },
1371 ];
1372 if (ObjectId.isValid(q) && q.length === 24) {
1373 conditions.push({ _id: new ObjectId(q) });
1374 }
1375 // Regex search on string fields
1376 const regex = { $regex: q, $options: "i" };
1377 conditions.push(
1378 { handle: regex }, { user: regex }, { name: regex },
1379 { slug: regex }, { text: regex }, { email: regex },
1380 { code: regex }, { mood: regex }, { piece: regex },
1381 );
1382 filter = { $or: conditions };
1383 }
1384 const [docs, total] = await Promise.all([
1385 col.find(filter).sort({ [sortField]: sortDir }).skip(skip).limit(limit).toArray(),
1386 col.countDocuments(filter),
1387 ]);
1388 res.json({ docs, total, skip, limit });
1389 } catch (err) {
1390 res.status(500).json({ error: err.message });
1391 }
1392});
1393
1394app.get("/api/db/browse/:collection/:id", async (req, res) => {
1395 if (!db) return res.status(503).json({ error: "Database not connected" });
1396 try {
1397 const col = db.collection(req.params.collection);
1398 const id = req.params.id;
1399 let doc = await col.findOne({ _id: id });
1400 if (!doc && ObjectId.isValid(id) && id.length === 24) {
1401 doc = await col.findOne({ _id: new ObjectId(id) });
1402 }
1403 if (!doc) return res.status(404).json({ error: "Document not found" });
1404 res.json(doc);
1405 } catch (err) {
1406 res.status(500).json({ error: err.message });
1407 }
1408});
1409
1410app.put("/api/db/browse/:collection/:id", async (req, res) => {
1411 if (!db) return res.status(503).json({ error: "Database not connected" });
1412 const { _id, ...update } = req.body;
1413 try {
1414 const col = db.collection(req.params.collection);
1415 const id = req.params.id;
1416 let result = await col.updateOne({ _id: id }, { $set: update });
1417 if (result.matchedCount === 0 && ObjectId.isValid(id) && id.length === 24) {
1418 result = await col.updateOne({ _id: new ObjectId(id) }, { $set: update });
1419 }
1420 if (result.matchedCount === 0) return res.status(404).json({ error: "Document not found" });
1421 log("info", `browse: updated ${req.params.collection}/${id}`);
1422 res.json({ ok: true, modified: result.modifiedCount });
1423 } catch (err) {
1424 res.status(500).json({ error: err.message });
1425 }
1426});
1427
1428app.delete("/api/db/browse/:collection/:id", async (req, res) => {
1429 if (!db) return res.status(503).json({ error: "Database not connected" });
1430 try {
1431 const col = db.collection(req.params.collection);
1432 const id = req.params.id;
1433 let result = await col.deleteOne({ _id: id });
1434 if (result.deletedCount === 0 && ObjectId.isValid(id) && id.length === 24) {
1435 result = await col.deleteOne({ _id: new ObjectId(id) });
1436 }
1437 if (result.deletedCount === 0) return res.status(404).json({ error: "Document not found" });
1438 log("info", `browse: deleted ${req.params.collection}/${id}`);
1439 res.json({ ok: true });
1440 } catch (err) {
1441 res.status(500).json({ error: err.message });
1442 }
1443});
1444
1445// --- Instagram Admin Routes ---
1446app.get("/api/insta/status", async (req, res) => {
1447 const sessionDoc = db
1448 ? await db.collection("insta-sessions").findOne({ _id: igSessionUsername }).catch(() => null)
1449 : null;
1450 res.json({
1451 loggedIn: igLoggedIn,
1452 username: igSessionUsername || null,
1453 lastUsed: igLastUsed ? new Date(igLastUsed).toISOString() : null,
1454 sessionAge: sessionDoc?.updatedAt
1455 ? Math.round((Date.now() - new Date(sessionDoc.updatedAt).getTime()) / 1000)
1456 : null,
1457 challengeInProgress: igChallengeInProgress,
1458 });
1459});
1460
1461app.post("/api/insta/login", async (req, res) => {
1462 if (!db) return res.status(500).json({ error: "Database not connected" });
1463
1464 const secrets = await db.collection("secrets").findOne({ _id: "instagram" });
1465 if (!secrets?.username || !secrets?.password) {
1466 return res.status(400).json({ error: "Instagram credentials not in MongoDB secrets" });
1467 }
1468
1469 const username = secrets.username;
1470 const password = secrets.password;
1471
1472 log("info", `insta login attempt for @${username}`);
1473
1474 igClient = new IgApiClient();
1475 igClient.state.generateDevice(username);
1476 igSessionUsername = username;
1477
1478 igClient.request.end$.subscribe(async () => {
1479 await saveInstaSession(igClient, username);
1480 });
1481
1482 // Pre-login flow (instagram-cli pattern)
1483 try {
1484 await igClient.launcher.preLoginSync();
1485 log("info", "insta preLoginSync OK");
1486 } catch (e) {
1487 log("warn", `insta preLoginSync failed: ${e.message}`);
1488 }
1489
1490 try {
1491 await igClient.account.login(username, password);
1492 igLoggedIn = true;
1493 igLastUsed = Date.now();
1494
1495 // Post-login flow
1496 try {
1497 await igClient.feed.reelsTray("cold_start").request();
1498 await igClient.feed.timeline("cold_start_fetch").request();
1499 } catch (e) {
1500 log("warn", `insta postLoginFlow failed: ${e.message}`);
1501 }
1502
1503 await saveInstaSession(igClient, username);
1504 log("info", `insta login success for @${username}`);
1505 res.json({ ok: true, username });
1506 } catch (err) {
1507 if (err instanceof IgCheckpointError) {
1508 log("info", "insta checkpoint triggered, requesting verification code");
1509 try {
1510 await igClient.challenge.auto(true);
1511 igChallengeInProgress = true;
1512 return res.json({
1513 ok: false,
1514 challenge: true,
1515 message: "Verification code sent (check email/SMS). Submit via /api/insta/challenge.",
1516 });
1517 } catch (challengeErr) {
1518 return res.status(500).json({ error: `Challenge setup failed: ${challengeErr.message}` });
1519 }
1520 }
1521
1522 if (err instanceof IgLoginTwoFactorRequiredError) {
1523 const twoFactorInfo = err.response.body.two_factor_info;
1524 igChallengeInProgress = true;
1525 return res.json({
1526 ok: false,
1527 twoFactor: true,
1528 method: twoFactorInfo.totp_two_factor_on ? "authenticator" : "sms",
1529 twoFactorIdentifier: twoFactorInfo.two_factor_identifier,
1530 message: "Enter 2FA code via /api/insta/challenge.",
1531 });
1532 }
1533
1534 if (err instanceof IgLoginBadPasswordError) {
1535 return res.status(401).json({ error: "Bad password. Check credentials in MongoDB secrets." });
1536 }
1537
1538 log("error", `insta login failed: ${err.message}`);
1539 return res.status(500).json({ error: err.message });
1540 }
1541});
1542
1543app.post("/api/insta/challenge", async (req, res) => {
1544 if (!igClient || !igChallengeInProgress) {
1545 return res.status(400).json({ error: "No challenge in progress" });
1546 }
1547
1548 const { code, twoFactorIdentifier, method } = req.body || {};
1549 if (!code) return res.status(400).json({ error: "code is required" });
1550
1551 try {
1552 if (twoFactorIdentifier) {
1553 // 2FA flow
1554 await igClient.account.twoFactorLogin({
1555 username: igSessionUsername,
1556 verificationCode: String(code).trim(),
1557 twoFactorIdentifier,
1558 verificationMethod: method === "authenticator" ? "0" : "1",
1559 });
1560 } else {
1561 // Checkpoint flow
1562 await igClient.challenge.sendSecurityCode(String(code).trim());
1563 }
1564
1565 igLoggedIn = true;
1566 igChallengeInProgress = false;
1567 igLastUsed = Date.now();
1568
1569 // Post-login flow
1570 try {
1571 await igClient.feed.reelsTray("cold_start").request();
1572 await igClient.feed.timeline("cold_start_fetch").request();
1573 } catch (e) {
1574 log("warn", `insta postLoginFlow failed: ${e.message}`);
1575 }
1576
1577 await saveInstaSession(igClient, igSessionUsername);
1578 log("info", `insta challenge completed for @${igSessionUsername}`);
1579 res.json({ ok: true, username: igSessionUsername });
1580 } catch (err) {
1581 log("error", `insta challenge failed: ${err.message}`);
1582 res.status(400).json({ error: `Challenge verification failed: ${err.message}` });
1583 }
1584});
1585
1586app.post("/api/insta/logout", async (req, res) => {
1587 igClient = null;
1588 igLoggedIn = false;
1589 igChallengeInProgress = false;
1590 igLastUsed = null;
1591
1592 if (db && igSessionUsername) {
1593 await db.collection("insta-sessions").deleteOne({ _id: igSessionUsername }).catch(() => {});
1594 }
1595
1596 log("info", `insta session cleared for @${igSessionUsername}`);
1597 igSessionUsername = null;
1598 res.json({ ok: true });
1599});
1600
1601// --- TikTok Admin Routes ---
1602app.get("/api/tiktok/status", async (req, res) => {
1603 const token = await loadTiktokSession();
1604 const sessionDoc = db
1605 ? await db.collection("tiktok-sessions").findOne({ _id: "default" }).catch(() => null)
1606 : null;
1607 res.json({
1608 connected: !!token?.access_token,
1609 username: token?.username || null,
1610 openId: token?.open_id || null,
1611 scope: token?.scope || null,
1612 lastUsed: tiktokLastUsed ? new Date(tiktokLastUsed).toISOString() : null,
1613 expiresAt: token?.expires_at ? new Date(token.expires_at).toISOString() : null,
1614 sessionAge: sessionDoc?.updatedAt
1615 ? Math.round((Date.now() - new Date(sessionDoc.updatedAt).getTime()) / 1000)
1616 : null,
1617 });
1618});
1619
1620app.get("/api/tiktok/auth", (req, res) => {
1621 if (!TIKTOK_CLIENT_KEY) return res.status(500).json({ error: "TIKTOK_CLIENT_KEY not configured" });
1622 const scopes = "user.info.basic,user.info.profile,user.info.stats,video.list";
1623 const url = `https://www.tiktok.com/v2/auth/authorize/?client_key=${TIKTOK_CLIENT_KEY}&scope=${scopes}&response_type=code&redirect_uri=${encodeURIComponent(TIKTOK_REDIRECT_URI)}`;
1624 res.json({ url });
1625});
1626
1627app.post("/api/tiktok/disconnect", async (req, res) => {
1628 tiktokToken = null;
1629 tiktokLastUsed = null;
1630 if (db) {
1631 await db.collection("tiktok-sessions").deleteOne({ _id: "default" }).catch(() => {});
1632 }
1633 log("info", "tiktok session disconnected");
1634 res.json({ ok: true });
1635});
1636
1637app.post("/api/tiktok/refresh", async (req, res) => {
1638 const ok = await refreshTiktokToken();
1639 res.json({ ok, token: ok ? { expiresAt: new Date(tiktokToken.expires_at).toISOString() } : null });
1640});
1641
1642// --- Desktop Release Distribution ---
1643const DESKTOP_BUCKET = "releases-aesthetic-computer";
1644const DESKTOP_PREFIX = "desktop/";
1645const DESKTOP_BASE_URL = "https://releases.aesthetic.computer/desktop";
1646
1647async function ensureReleasesCollection() {
1648 if (!db) return;
1649 try {
1650 const col = db.collection("releases");
1651 await col.createIndex({ version: 1 }, { unique: true });
1652 await col.createIndex({ current: 1 });
1653 log("info", "releases collection ready");
1654 } catch (err) {
1655 log("error", `releases index setup: ${err.message}`);
1656 }
1657}
1658
1659// Public routes (CORS-enabled, no auth)
1660app.use("/desktop", (req, res, next) => {
1661 res.header("Access-Control-Allow-Origin", "*");
1662 res.header("Access-Control-Allow-Methods", "GET, OPTIONS");
1663 res.header("Access-Control-Allow-Headers", "Content-Type");
1664 if (req.method === "OPTIONS") return res.sendStatus(200);
1665 next();
1666});
1667
1668app.get("/desktop/latest", async (req, res) => {
1669 if (!db) return res.status(503).json({ error: "Database not connected" });
1670 try {
1671 const release = await db.collection("releases").findOne({ current: true });
1672 if (!release) return res.status(404).json({ error: "No release published" });
1673 res.json({
1674 version: release.version,
1675 publishedAt: release.publishedAt,
1676 releaseNotes: release.releaseNotes || null,
1677 platforms: release.platforms,
1678 });
1679 } catch (err) {
1680 res.status(500).json({ error: err.message });
1681 }
1682});
1683
1684app.get("/desktop/download/:platform", async (req, res) => {
1685 if (!db) return res.status(503).json({ error: "Database not connected" });
1686 const platform = req.params.platform;
1687 try {
1688 const release = await db.collection("releases").findOne({ current: true });
1689 if (!release) return res.status(404).json({ error: "No release published" });
1690 const plat = release.platforms?.[platform];
1691 if (!plat?.url) return res.status(404).json({ error: `No ${platform} build available` });
1692 res.redirect(302, plat.url);
1693 } catch (err) {
1694 res.status(500).json({ error: err.message });
1695 }
1696});
1697
1698// Admin routes
1699app.post("/api/desktop/register", async (req, res) => {
1700 if (!db) return res.status(503).json({ error: "Database not connected" });
1701 const { version, platforms, releaseNotes } = req.body;
1702 if (!version || !platforms) {
1703 return res.status(400).json({ error: "version and platforms required" });
1704 }
1705
1706 // Build full URLs from filenames
1707 for (const [key, plat] of Object.entries(platforms)) {
1708 if (plat.filename && !plat.url) {
1709 plat.url = `${DESKTOP_BASE_URL}/${plat.filename}`;
1710 }
1711 }
1712
1713 try {
1714 // Unset current on all previous releases
1715 await db.collection("releases").updateMany({}, { $set: { current: false } });
1716 // Upsert this release
1717 await db.collection("releases").updateOne(
1718 { version },
1719 {
1720 $set: {
1721 version,
1722 platforms,
1723 releaseNotes: releaseNotes || null,
1724 publishedAt: new Date(),
1725 publishedBy: req.auth?.handle || "unknown",
1726 current: true,
1727 },
1728 },
1729 { upsert: true },
1730 );
1731 log("info", `desktop release registered: v${version} by @${req.auth?.handle}`);
1732 res.json({ ok: true, version });
1733 } catch (err) {
1734 log("error", `desktop register failed: ${err.message}`);
1735 res.status(500).json({ error: err.message });
1736 }
1737});
1738
1739app.get("/api/desktop/releases", async (req, res) => {
1740 if (!db) return res.json({ releases: [] });
1741 try {
1742 const releases = await db.collection("releases")
1743 .find({}).sort({ publishedAt: -1 }).limit(20).toArray();
1744 res.json({ releases });
1745 } catch (err) {
1746 res.status(500).json({ error: err.message });
1747 }
1748});
1749
1750// --- Dashboard (loaded from file, reloadable via SIGHUP) ---
1751const DASHBOARD_PATH = path.join(__dirname, "dashboard.html");
1752let dashboardHtml = "";
1753
1754function loadDashboard() {
1755 try {
1756 dashboardHtml = fs.readFileSync(DASHBOARD_PATH, "utf-8");
1757 log("info", `dashboard loaded (${(dashboardHtml.length / 1024).toFixed(0)} KB)`);
1758 } catch (err) {
1759 log("error", `dashboard load failed: ${err.message}`);
1760 }
1761}
1762loadDashboard();
1763
1764app.get("/", (req, res) => {
1765 res.setHeader("Content-Type", "text/html");
1766 res.send(dashboardHtml);
1767});
1768
1769// --- 404 ---
1770app.use((req, res) => res.status(404).json({ error: "Not found" }));
1771
1772// --- Server Start ---
1773let server;
1774if (dev) {
1775 try {
1776 const httpsOpts = {
1777 key: fs.readFileSync("../ssl-dev/localhost-key.pem"),
1778 cert: fs.readFileSync("../ssl-dev/localhost.pem"),
1779 };
1780 server = https.createServer(httpsOpts, app);
1781 } catch {
1782 log("warn", "no SSL certs, falling back to HTTP");
1783 server = http.createServer(app);
1784 }
1785} else {
1786 server = http.createServer(app);
1787}
1788
1789// --- WebSocket ---
1790wss = new WebSocketServer({ server, path: "/ws" });
1791
1792wss.on("connection", async (ws) => {
1793 log("info", "dashboard client connected");
1794 for (const entry of activityLog.slice(0, 30).reverse()) {
1795 ws.send(JSON.stringify({ logEntry: entry }));
1796 }
1797 ws.on("close", () => log("info", "dashboard client disconnected"));
1798});
1799
1800// --- Boot ---
1801await connectMongo();
1802await loadHandleCache();
1803await ensureFirehoseCollection();
1804await ensureReleasesCollection();
1805startFirehose();
1806await connectAtlas();
1807await connectRedis();
1808await connectRedisSub();
1809
1810// Restore TikTok session on startup
1811loadTiktokSession().catch(() => {});
1812
1813server.listen(PORT, () => {
1814 const proto = dev ? "https" : "http";
1815 log("info", `silo running on ${proto}://localhost:${PORT}`);
1816});
1817
1818// --- Shutdown ---
1819function shutdown(signal) {
1820 log("info", `received ${signal}, shutting down...`);
1821 if (changeStream) changeStream.close().catch(() => {});
1822 wss.clients.forEach((ws) => ws.close());
1823 server.close();
1824 mongoClient?.close();
1825 if (atlasClient && atlasClient !== mongoClient) atlasClient?.close();
1826 redisClient?.quit().catch(() => {});
1827 redisSub?.quit().catch(() => {});
1828 setTimeout(() => process.exit(0), 500);
1829}
1830process.on("SIGTERM", () => shutdown("SIGTERM"));
1831process.on("SIGINT", () => shutdown("SIGINT"));
1832
1833// --- SIGHUP: hot-reload dashboard.html without dropping connections ---
1834process.on("SIGHUP", () => {
1835 log("info", "SIGHUP received, reloading dashboard...");
1836 loadDashboard();
1837});