Monorepo for Aesthetic.Computer aesthetic.computer
at main 1837 lines 65 kB view raw
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});