A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 3131 lines 96 kB view raw
1import { execSync, spawn } from 'node:child_process'; 2import { randomBytes, randomUUID } from 'node:crypto'; 3import fs from 'node:fs'; 4import path from 'node:path'; 5import { fileURLToPath } from 'node:url'; 6import axios from 'axios'; 7import bcrypt from 'bcryptjs'; 8import cors from 'cors'; 9import express from 'express'; 10import jwt, { type SignOptions } from 'jsonwebtoken'; 11import { deleteAllPosts } from './bsky.js'; 12import { 13 ADMIN_USER_PERMISSIONS, 14 type AccountMapping, 15 type AppConfig, 16 type UserPermissions, 17 type UserRole, 18 type WebUser, 19 getConfig, 20 getDefaultUserPermissions, 21 saveConfig, 22} from './config-manager.js'; 23import { dbService } from './db.js'; 24import type { ProcessedTweet } from './db.js'; 25import { 26 applyProfileMirrorSyncState, 27 bridgeBlueskyAccountToFediverse, 28 ensureBlueskyBotSelfLabel, 29 ensureBlueskyDisplayNameBotSuffix, 30 fetchTwitterMirrorProfile, 31 syncBlueskyProfileFromTwitter, 32 validateBlueskyCredentials, 33} from './profile-mirror.js'; 34import { JWT_SECRET_FILE_PATH, UPDATE_LOG_DIR } from './storage-paths.js'; 35 36const __filename = fileURLToPath(import.meta.url); 37const __dirname = path.dirname(__filename); 38 39const app = express(); 40const PORT = Number(process.env.PORT) || 3000; 41const HOST = (process.env.HOST || process.env.BIND_HOST || '0.0.0.0').trim() || '0.0.0.0'; 42const APP_ROOT_DIR = path.join(__dirname, '..'); 43const jwtSecretFromEnv = process.env.JWT_SECRET?.trim(); 44const JWT_EXPIRES_IN = ((process.env.JWT_EXPIRES_IN || '30d').trim() || '30d') as SignOptions['expiresIn']; 45const WEB_DIST_DIR = path.join(APP_ROOT_DIR, 'web', 'dist'); 46const LEGACY_PUBLIC_DIR = path.join(APP_ROOT_DIR, 'public'); 47const PACKAGE_JSON_PATH = path.join(APP_ROOT_DIR, 'package.json'); 48const UPDATE_SCRIPT_PATH = path.join(APP_ROOT_DIR, 'update.sh'); 49const staticAssetsDir = fs.existsSync(path.join(WEB_DIST_DIR, 'index.html')) ? WEB_DIST_DIR : LEGACY_PUBLIC_DIR; 50const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app'; 51const POST_VIEW_CACHE_TTL_MS = 60_000; 52const PROFILE_CACHE_TTL_MS = 5 * 60_000; 53const RESERVED_UNGROUPED_KEY = 'ungrouped'; 54const SERVER_STARTED_AT = Date.now(); 55const PASSWORD_MIN_LENGTH = 8; 56const AUTH_RATE_WINDOW_MS = 15 * 60 * 1000; 57const AUTH_RATE_MAX_ATTEMPTS = 30; 58const APPVIEW_POST_CHUNK_SIZE = 10; 59const APPVIEW_PROFILE_CHUNK_SIZE = 25; 60const APPVIEW_MAX_ATTEMPTS = 2; 61const APPVIEW_RETRY_DELAY_MS = 700; 62const FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE = 2; 63const FEDIVERSE_BRIDGE_STATUS_CACHE_TTL_MS = 10 * 60_000; 64const FEDIVERSE_BRIDGE_HANDLES = ['ap.brid.gy', 'bsky.brid.gy']; 65 66function loadPersistedJwtSecret(): string | undefined { 67 if (!fs.existsSync(JWT_SECRET_FILE_PATH)) { 68 return undefined; 69 } 70 71 try { 72 const secret = fs.readFileSync(JWT_SECRET_FILE_PATH, 'utf8').trim(); 73 if (secret.length >= 32) { 74 return secret; 75 } 76 console.warn(`⚠️ Ignoring weak JWT secret in ${JWT_SECRET_FILE_PATH}. Regenerating.`); 77 return undefined; 78 } catch (error) { 79 console.warn( 80 `⚠️ Failed reading JWT secret file at ${JWT_SECRET_FILE_PATH}: ${(error as Error).message}. Regenerating.`, 81 ); 82 return undefined; 83 } 84} 85 86function persistJwtSecret(secret: string): void { 87 fs.mkdirSync(path.dirname(JWT_SECRET_FILE_PATH), { recursive: true }); 88 fs.writeFileSync(JWT_SECRET_FILE_PATH, `${secret}\n`, { mode: 0o600 }); 89 try { 90 fs.chmodSync(JWT_SECRET_FILE_PATH, 0o600); 91 } catch { 92 // Best effort on non-POSIX filesystems. 93 } 94} 95 96function resolveJwtSecret(): string { 97 if (jwtSecretFromEnv) { 98 if (jwtSecretFromEnv.length < 32) { 99 console.warn('⚠️ JWT_SECRET is shorter than 32 characters. Use a longer value for stronger signing security.'); 100 } 101 return jwtSecretFromEnv; 102 } 103 104 const persisted = loadPersistedJwtSecret(); 105 if (persisted) { 106 return persisted; 107 } 108 109 const generated = randomBytes(48).toString('hex'); 110 persistJwtSecret(generated); 111 console.warn( 112 `⚠️ JWT_SECRET not set. Generated persistent signing secret at ${JWT_SECRET_FILE_PATH}. Keep this file private.`, 113 ); 114 return generated; 115} 116 117const JWT_SECRET = resolveJwtSecret(); 118 119interface CacheEntry<T> { 120 value: T; 121 expiresAt: number; 122} 123 124interface BskyProfileView { 125 did?: string; 126 handle?: string; 127 displayName?: string; 128 avatar?: string; 129 description?: string; 130 createdAt?: string; 131} 132 133interface FediverseBridgeStatusView { 134 bridged: boolean; 135 checkedAt: string; 136 error?: string; 137} 138 139interface EnrichedPostMedia { 140 type: 'image' | 'video' | 'external'; 141 url?: string; 142 thumb?: string; 143 alt?: string; 144 width?: number; 145 height?: number; 146 title?: string; 147 description?: string; 148} 149 150interface EnrichedPost { 151 bskyUri: string; 152 bskyCid?: string; 153 bskyIdentifier: string; 154 twitterId: string; 155 twitterUsername: string; 156 twitterUrl?: string; 157 postUrl?: string; 158 createdAt?: string; 159 text: string; 160 facets: unknown[]; 161 author: { 162 did?: string; 163 handle: string; 164 displayName?: string; 165 avatar?: string; 166 }; 167 stats: { 168 likes: number; 169 reposts: number; 170 replies: number; 171 quotes: number; 172 engagement: number; 173 }; 174 media: EnrichedPostMedia[]; 175} 176 177interface LocalPostSearchResult { 178 twitterId: string; 179 twitterUsername: string; 180 bskyIdentifier: string; 181 tweetText?: string; 182 bskyUri?: string; 183 bskyCid?: string; 184 createdAt?: string; 185 postUrl?: string; 186 twitterUrl?: string; 187 score: number; 188} 189 190interface RuntimeVersionInfo { 191 version: string; 192 commit?: string; 193 branch?: string; 194 startedAt: number; 195} 196 197interface UpdateJobState { 198 running: boolean; 199 pid?: number; 200 startedAt?: number; 201 startedBy?: string; 202 finishedAt?: number; 203 exitCode?: number | null; 204 signal?: NodeJS.Signals | null; 205 logFile?: string; 206} 207 208interface UpdateStatusPayload { 209 running: boolean; 210 pid?: number; 211 startedAt?: number; 212 startedBy?: string; 213 finishedAt?: number; 214 exitCode?: number | null; 215 signal?: NodeJS.Signals | null; 216 logFile?: string; 217 logTail: string[]; 218} 219 220const postViewCache = new Map<string, CacheEntry<any>>(); 221const profileCache = new Map<string, CacheEntry<BskyProfileView>>(); 222const fediverseBridgeStatusCache = new Map<string, CacheEntry<FediverseBridgeStatusView>>(); 223let fediverseBridgeActorIdsCache: CacheEntry<Set<string>> | null = null; 224 225function chunkArray<T>(items: T[], size: number): T[][] { 226 if (size <= 0) return [items]; 227 const chunks: T[][] = []; 228 for (let i = 0; i < items.length; i += size) { 229 chunks.push(items.slice(i, i + size)); 230 } 231 return chunks; 232} 233 234function nowMs() { 235 return Date.now(); 236} 237 238const parseAllowedOrigins = (): Set<string> => { 239 const raw = process.env.CORS_ALLOWED_ORIGINS || process.env.CORS_ORIGIN || ''; 240 const origins = raw 241 .split(',') 242 .map((entry) => entry.trim()) 243 .filter((entry) => entry.length > 0); 244 return new Set(origins); 245}; 246 247const allowedOrigins = parseAllowedOrigins(); 248 249interface RateLimitBucket { 250 count: number; 251 resetAt: number; 252} 253 254const authRateBuckets = new Map<string, RateLimitBucket>(); 255 256const getRequestIp = (req: any): string => { 257 const forwarded = req.headers?.['x-forwarded-for']; 258 if (typeof forwarded === 'string' && forwarded.trim().length > 0) { 259 const [first] = forwarded.split(','); 260 if (first && first.trim().length > 0) { 261 return first.trim(); 262 } 263 } 264 if (typeof req.ip === 'string' && req.ip.length > 0) { 265 return req.ip; 266 } 267 if (typeof req.socket?.remoteAddress === 'string' && req.socket.remoteAddress.length > 0) { 268 return req.socket.remoteAddress; 269 } 270 return 'unknown'; 271}; 272 273const authRateLimiter = (req: any, res: any, next: any) => { 274 const now = nowMs(); 275 if (authRateBuckets.size > 5000) { 276 for (const [bucketKey, bucketValue] of authRateBuckets.entries()) { 277 if (bucketValue.resetAt <= now) { 278 authRateBuckets.delete(bucketKey); 279 } 280 } 281 } 282 283 const ip = getRequestIp(req); 284 const key = `auth:${ip}`; 285 const bucket = authRateBuckets.get(key); 286 287 if (!bucket || bucket.resetAt <= now) { 288 authRateBuckets.set(key, { 289 count: 1, 290 resetAt: now + AUTH_RATE_WINDOW_MS, 291 }); 292 next(); 293 return; 294 } 295 296 if (bucket.count >= AUTH_RATE_MAX_ATTEMPTS) { 297 const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); 298 res.setHeader('Retry-After', String(retryAfterSeconds)); 299 res.status(429).json({ 300 error: `Too many authentication attempts. Try again in about ${retryAfterSeconds} seconds.`, 301 }); 302 return; 303 } 304 305 bucket.count += 1; 306 authRateBuckets.set(key, bucket); 307 next(); 308}; 309 310function buildPostUrl(identifier: string, uri?: string): string | undefined { 311 if (!uri) return undefined; 312 const rkey = uri.split('/').filter(Boolean).pop(); 313 if (!rkey) return undefined; 314 return `https://bsky.app/profile/${identifier}/post/${rkey}`; 315} 316 317function buildTwitterPostUrl(username: string, twitterId: string): string | undefined { 318 if (!username || !twitterId) return undefined; 319 return `https://x.com/${normalizeActor(username)}/status/${twitterId}`; 320} 321 322function normalizeActor(actor: string): string { 323 return actor.trim().replace(/^@/, '').toLowerCase(); 324} 325 326function normalizeGroupName(value: unknown): string { 327 return typeof value === 'string' ? value.trim() : ''; 328} 329 330function normalizeGroupEmoji(value: unknown): string { 331 return typeof value === 'string' ? value.trim() : ''; 332} 333 334function getNormalizedGroupKey(value: unknown): string { 335 return normalizeGroupName(value).toLowerCase(); 336} 337 338function ensureGroupExists(config: AppConfig, name?: string, emoji?: string) { 339 const normalizedName = normalizeGroupName(name); 340 if (!normalizedName || getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) return; 341 342 if (!Array.isArray(config.groups)) { 343 config.groups = []; 344 } 345 346 const existingIndex = config.groups.findIndex( 347 (group) => getNormalizedGroupKey(group.name) === getNormalizedGroupKey(normalizedName), 348 ); 349 const normalizedEmoji = normalizeGroupEmoji(emoji); 350 351 if (existingIndex === -1) { 352 config.groups.push({ 353 name: normalizedName, 354 ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}), 355 }); 356 return; 357 } 358 359 if (normalizedEmoji) { 360 const existingGroupName = normalizeGroupName(config.groups[existingIndex]?.name) || normalizedName; 361 config.groups[existingIndex] = { 362 name: existingGroupName, 363 emoji: normalizedEmoji, 364 }; 365 } 366} 367 368function safeExec(command: string, cwd = APP_ROOT_DIR): string | undefined { 369 try { 370 return execSync(command, { 371 cwd, 372 stdio: ['ignore', 'pipe', 'ignore'], 373 encoding: 'utf8', 374 }).trim(); 375 } catch { 376 return undefined; 377 } 378} 379 380function getRuntimeVersionInfo(): RuntimeVersionInfo { 381 let version = 'unknown'; 382 try { 383 const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')); 384 if (typeof pkg?.version === 'string' && pkg.version.trim().length > 0) { 385 version = pkg.version.trim(); 386 } 387 } catch { 388 // Ignore parse/read failures and keep fallback. 389 } 390 391 return { 392 version, 393 commit: safeExec('git rev-parse --short HEAD'), 394 branch: safeExec('git rev-parse --abbrev-ref HEAD'), 395 startedAt: SERVER_STARTED_AT, 396 }; 397} 398 399function isProcessAlive(pid?: number): boolean { 400 if (!pid || pid <= 0) return false; 401 try { 402 process.kill(pid, 0); 403 return true; 404 } catch { 405 return false; 406 } 407} 408 409function readLogTail(logFile?: string, maxLines = 30): string[] { 410 if (!logFile || !fs.existsSync(logFile)) { 411 return []; 412 } 413 414 try { 415 const raw = fs.readFileSync(logFile, 'utf8'); 416 const lines = raw.split(/\r?\n/).filter((line) => line.length > 0); 417 return lines.slice(-maxLines); 418 } catch { 419 return []; 420 } 421} 422 423function extractMediaFromEmbed(embed: any): EnrichedPostMedia[] { 424 if (!embed || typeof embed !== 'object') { 425 return []; 426 } 427 428 const type = embed.$type; 429 if (type === 'app.bsky.embed.images#view') { 430 const images = Array.isArray(embed.images) ? embed.images : []; 431 return images.map((image: any) => ({ 432 type: 'image' as const, 433 url: typeof image.fullsize === 'string' ? image.fullsize : undefined, 434 thumb: typeof image.thumb === 'string' ? image.thumb : undefined, 435 alt: typeof image.alt === 'string' ? image.alt : undefined, 436 width: typeof image.aspectRatio?.width === 'number' ? image.aspectRatio.width : undefined, 437 height: typeof image.aspectRatio?.height === 'number' ? image.aspectRatio.height : undefined, 438 })); 439 } 440 441 if (type === 'app.bsky.embed.video#view') { 442 return [ 443 { 444 type: 'video', 445 url: typeof embed.playlist === 'string' ? embed.playlist : undefined, 446 thumb: typeof embed.thumbnail === 'string' ? embed.thumbnail : undefined, 447 alt: typeof embed.alt === 'string' ? embed.alt : undefined, 448 width: typeof embed.aspectRatio?.width === 'number' ? embed.aspectRatio.width : undefined, 449 height: typeof embed.aspectRatio?.height === 'number' ? embed.aspectRatio.height : undefined, 450 }, 451 ]; 452 } 453 454 if (type === 'app.bsky.embed.external#view') { 455 const external = embed.external || {}; 456 return [ 457 { 458 type: 'external', 459 url: typeof external.uri === 'string' ? external.uri : undefined, 460 thumb: typeof external.thumb === 'string' ? external.thumb : undefined, 461 title: typeof external.title === 'string' ? external.title : undefined, 462 description: typeof external.description === 'string' ? external.description : undefined, 463 }, 464 ]; 465 } 466 467 if (type === 'app.bsky.embed.recordWithMedia#view') { 468 return extractMediaFromEmbed(embed.media); 469 } 470 471 return []; 472} 473 474const RETRYABLE_APPVIEW_CODES = new Set(['ETIMEDOUT', 'ECONNABORTED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN']); 475 476function describeAxiosError(error: unknown): string { 477 if (axios.isAxiosError(error)) { 478 const details = [error.message]; 479 const status = error.response?.status; 480 const code = error.code; 481 const causeCode = 482 typeof (error as { cause?: { code?: unknown } }).cause?.code === 'string' 483 ? (error as { cause?: { code?: string } }).cause?.code 484 : undefined; 485 486 if (typeof status === 'number') { 487 details.push(`status=${status}`); 488 } 489 if (typeof code === 'string') { 490 details.push(`code=${code}`); 491 } 492 if (typeof causeCode === 'string' && causeCode !== code) { 493 details.push(`cause=${causeCode}`); 494 } 495 return details.join(', '); 496 } 497 498 if (error instanceof Error) { 499 return error.message; 500 } 501 return String(error); 502} 503 504function isRetryableAppviewError(error: unknown): boolean { 505 if (!axios.isAxiosError(error)) { 506 return false; 507 } 508 509 const status = error.response?.status; 510 if (typeof status === 'number' && (status >= 500 || status === 429)) { 511 return true; 512 } 513 514 const code = error.code; 515 if (typeof code === 'string' && RETRYABLE_APPVIEW_CODES.has(code)) { 516 return true; 517 } 518 519 const causeCode = (error as { cause?: { code?: unknown } }).cause?.code; 520 if (typeof causeCode === 'string' && RETRYABLE_APPVIEW_CODES.has(causeCode)) { 521 return true; 522 } 523 524 return false; 525} 526 527const sleep = (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)); 528 529async function fetchAppview(pathname: string, params: URLSearchParams, context: string): Promise<any | null> { 530 const url = `${BSKY_APPVIEW_URL}${pathname}?${params.toString()}`; 531 for (let attempt = 1; attempt <= APPVIEW_MAX_ATTEMPTS; attempt += 1) { 532 try { 533 const response = await axios.get(url, { timeout: 12_000 }); 534 return response.data; 535 } catch (error) { 536 const retryable = isRetryableAppviewError(error); 537 const canRetry = retryable && attempt < APPVIEW_MAX_ATTEMPTS; 538 console.warn( 539 `[AppView] ${context} failed (attempt ${attempt}/${APPVIEW_MAX_ATTEMPTS}): ${describeAxiosError(error)}${canRetry ? '. Retrying...' : ''}`, 540 ); 541 if (!canRetry) { 542 return null; 543 } 544 await sleep(APPVIEW_RETRY_DELAY_MS * attempt); 545 } 546 } 547 return null; 548} 549 550async function fetchPostViewsByUri(uris: string[]): Promise<Map<string, any>> { 551 const result = new Map<string, any>(); 552 const uniqueUris = [...new Set(uris.filter((uri) => typeof uri === 'string' && uri.length > 0))]; 553 const pendingUris: string[] = []; 554 555 for (const uri of uniqueUris) { 556 const cached = postViewCache.get(uri); 557 if (cached && cached.expiresAt > nowMs()) { 558 result.set(uri, cached.value); 559 continue; 560 } 561 pendingUris.push(uri); 562 } 563 564 for (const chunk of chunkArray(pendingUris, APPVIEW_POST_CHUNK_SIZE)) { 565 if (chunk.length === 0) continue; 566 const params = new URLSearchParams(); 567 for (const uri of chunk) params.append('uris', uri); 568 569 const responseData = await fetchAppview('/xrpc/app.bsky.feed.getPosts', params, `getPosts chunk=${chunk.length}`); 570 if (!responseData) { 571 continue; 572 } 573 574 const posts = Array.isArray(responseData.posts) ? responseData.posts : []; 575 for (const post of posts) { 576 const uri = typeof post?.uri === 'string' ? post.uri : undefined; 577 if (!uri) continue; 578 postViewCache.set(uri, { 579 value: post, 580 expiresAt: nowMs() + POST_VIEW_CACHE_TTL_MS, 581 }); 582 result.set(uri, post); 583 } 584 } 585 586 return result; 587} 588 589async function fetchProfilesByActor(actors: string[]): Promise<Record<string, BskyProfileView>> { 590 const uniqueActors = [...new Set(actors.map(normalizeActor).filter((actor) => actor.length > 0))]; 591 const result: Record<string, BskyProfileView> = {}; 592 const pendingActors: string[] = []; 593 594 for (const actor of uniqueActors) { 595 const cached = profileCache.get(actor); 596 if (cached && cached.expiresAt > nowMs()) { 597 result[actor] = cached.value; 598 continue; 599 } 600 pendingActors.push(actor); 601 } 602 603 for (const chunk of chunkArray(pendingActors, APPVIEW_PROFILE_CHUNK_SIZE)) { 604 if (chunk.length === 0) continue; 605 const params = new URLSearchParams(); 606 for (const actor of chunk) params.append('actors', actor); 607 608 const responseData = await fetchAppview( 609 '/xrpc/app.bsky.actor.getProfiles', 610 params, 611 `getProfiles chunk=${chunk.length}`, 612 ); 613 if (!responseData) { 614 continue; 615 } 616 617 const profiles = Array.isArray(responseData.profiles) ? responseData.profiles : []; 618 for (const profile of profiles) { 619 const view: BskyProfileView = { 620 did: typeof profile?.did === 'string' ? profile.did : undefined, 621 handle: typeof profile?.handle === 'string' ? profile.handle : undefined, 622 displayName: typeof profile?.displayName === 'string' ? profile.displayName : undefined, 623 avatar: typeof profile?.avatar === 'string' ? profile.avatar : undefined, 624 description: typeof profile?.description === 'string' ? profile.description : undefined, 625 createdAt: typeof profile?.createdAt === 'string' ? profile.createdAt : undefined, 626 }; 627 628 const keys = [ 629 typeof view.handle === 'string' ? normalizeActor(view.handle) : '', 630 typeof view.did === 'string' ? normalizeActor(view.did) : '', 631 ].filter((key) => key.length > 0); 632 633 for (const key of keys) { 634 profileCache.set(key, { value: view, expiresAt: nowMs() + PROFILE_CACHE_TTL_MS }); 635 result[key] = view; 636 } 637 } 638 } 639 640 for (const actor of uniqueActors) { 641 const cached = profileCache.get(actor); 642 if (cached && cached.expiresAt > nowMs()) { 643 result[actor] = cached.value; 644 } 645 } 646 647 return result; 648} 649 650async function getFediverseBridgeActorIds(): Promise<Set<string>> { 651 const cached = fediverseBridgeActorIdsCache; 652 if (cached && cached.expiresAt > nowMs()) { 653 return new Set(cached.value); 654 } 655 656 const ids = new Set<string>(FEDIVERSE_BRIDGE_HANDLES.map((handle) => normalizeActor(handle))); 657 const profiles = await fetchProfilesByActor(FEDIVERSE_BRIDGE_HANDLES); 658 for (const profile of Object.values(profiles)) { 659 if (typeof profile?.handle === 'string' && profile.handle.length > 0) { 660 ids.add(normalizeActor(profile.handle)); 661 } 662 if (typeof profile?.did === 'string' && profile.did.length > 0) { 663 ids.add(normalizeActor(profile.did)); 664 } 665 } 666 667 fediverseBridgeActorIdsCache = { 668 value: ids, 669 expiresAt: nowMs() + PROFILE_CACHE_TTL_MS, 670 }; 671 672 return new Set(ids); 673} 674 675async function isActorFollowingFediverseBridge(actor: string): Promise<FediverseBridgeStatusView> { 676 const normalizedActor = normalizeActor(actor); 677 const checkedAt = new Date().toISOString(); 678 if (!normalizedActor) { 679 return { 680 bridged: false, 681 checkedAt, 682 error: 'Missing actor identifier.', 683 }; 684 } 685 686 const bridgeActorIds = await getFediverseBridgeActorIds(); 687 let cursor: string | undefined; 688 let pageCount = 0; 689 690 while (pageCount < 200) { 691 pageCount += 1; 692 const params = new URLSearchParams(); 693 params.set('actor', normalizedActor); 694 params.set('limit', '100'); 695 if (cursor) { 696 params.set('cursor', cursor); 697 } 698 699 const responseData = await fetchAppview( 700 '/xrpc/app.bsky.graph.getFollows', 701 params, 702 `getFollows actor=${normalizedActor}`, 703 ); 704 if (!responseData) { 705 return { 706 bridged: false, 707 checkedAt, 708 error: 'Failed to read follows from Bluesky AppView.', 709 }; 710 } 711 712 const follows = Array.isArray(responseData.follows) ? responseData.follows : []; 713 for (const follow of follows) { 714 const followedHandle = typeof follow?.handle === 'string' ? normalizeActor(follow.handle) : ''; 715 const followedDid = typeof follow?.did === 'string' ? normalizeActor(follow.did) : ''; 716 if ((followedHandle && bridgeActorIds.has(followedHandle)) || (followedDid && bridgeActorIds.has(followedDid))) { 717 return { 718 bridged: true, 719 checkedAt, 720 }; 721 } 722 } 723 724 cursor = 725 typeof responseData.cursor === 'string' && responseData.cursor.length > 0 ? responseData.cursor : undefined; 726 if (!cursor) { 727 break; 728 } 729 } 730 731 return { 732 bridged: false, 733 checkedAt, 734 }; 735} 736 737async function fetchFediverseBridgeStatusesByActor( 738 actors: string[], 739): Promise<Record<string, FediverseBridgeStatusView>> { 740 const uniqueActors = [...new Set(actors.map(normalizeActor).filter((actor) => actor.length > 0))]; 741 const result: Record<string, FediverseBridgeStatusView> = {}; 742 const pendingActors: string[] = []; 743 744 for (const actor of uniqueActors) { 745 const cached = fediverseBridgeStatusCache.get(actor); 746 if (cached && cached.expiresAt > nowMs()) { 747 result[actor] = cached.value; 748 continue; 749 } 750 pendingActors.push(actor); 751 } 752 753 for (const chunk of chunkArray(pendingActors, FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE)) { 754 if (chunk.length === 0) { 755 continue; 756 } 757 758 const chunkResults = await Promise.all( 759 chunk.map(async (actor) => { 760 try { 761 const status = await isActorFollowingFediverseBridge(actor); 762 return { actor, status }; 763 } catch (error) { 764 return { 765 actor, 766 status: { 767 bridged: false, 768 checkedAt: new Date().toISOString(), 769 error: getErrorMessage(error, 'Failed to check fediverse bridge status.'), 770 } satisfies FediverseBridgeStatusView, 771 }; 772 } 773 }), 774 ); 775 776 for (const item of chunkResults) { 777 fediverseBridgeStatusCache.set(item.actor, { 778 value: item.status, 779 expiresAt: nowMs() + FEDIVERSE_BRIDGE_STATUS_CACHE_TTL_MS, 780 }); 781 result[item.actor] = item.status; 782 } 783 } 784 785 return result; 786} 787 788function buildEnrichedPost(activity: ProcessedTweet, postView: any): EnrichedPost { 789 const record = postView?.record || {}; 790 const author = postView?.author || {}; 791 const likes = Number(postView?.likeCount) || 0; 792 const reposts = Number(postView?.repostCount) || 0; 793 const replies = Number(postView?.replyCount) || 0; 794 const quotes = Number(postView?.quoteCount) || 0; 795 796 const identifier = 797 (typeof activity.bsky_identifier === 'string' && activity.bsky_identifier.length > 0 798 ? activity.bsky_identifier 799 : typeof author.handle === 'string' 800 ? author.handle 801 : 'unknown') || 'unknown'; 802 803 return { 804 bskyUri: activity.bsky_uri || '', 805 bskyCid: typeof postView?.cid === 'string' ? postView.cid : activity.bsky_cid, 806 bskyIdentifier: identifier, 807 twitterId: activity.twitter_id, 808 twitterUsername: activity.twitter_username, 809 twitterUrl: buildTwitterPostUrl(activity.twitter_username, activity.twitter_id), 810 postUrl: buildPostUrl(identifier, activity.bsky_uri), 811 createdAt: 812 (typeof record.createdAt === 'string' ? record.createdAt : undefined) || 813 activity.created_at || 814 (typeof postView?.indexedAt === 'string' ? postView.indexedAt : undefined), 815 text: 816 (typeof record.text === 'string' ? record.text : undefined) || 817 activity.tweet_text || 818 `Tweet ID: ${activity.twitter_id}`, 819 facets: Array.isArray(record.facets) ? record.facets : [], 820 author: { 821 did: typeof author.did === 'string' ? author.did : undefined, 822 handle: typeof author.handle === 'string' && author.handle.length > 0 ? author.handle : activity.bsky_identifier, 823 displayName: typeof author.displayName === 'string' ? author.displayName : undefined, 824 avatar: typeof author.avatar === 'string' ? author.avatar : undefined, 825 }, 826 stats: { 827 likes, 828 reposts, 829 replies, 830 quotes, 831 engagement: likes + reposts + replies + quotes, 832 }, 833 media: extractMediaFromEmbed(postView?.embed), 834 }; 835} 836 837// In-memory state for triggers and scheduling 838let lastCheckTime = Date.now(); 839let nextCheckTime = Date.now() + (getConfig().checkIntervalMinutes || 5) * 60 * 1000; 840export interface PendingBackfill { 841 id: string; 842 limit?: number; 843 queuedAt: number; 844 sequence: number; 845 requestId: string; 846} 847let pendingBackfills: PendingBackfill[] = []; 848let backfillSequence = 0; 849let schedulerWakeSignal = 0; // Monotonic counter to wake scheduler loop immediately. 850 851interface AppStatus { 852 state: 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 853 currentAccount?: string; 854 processedCount?: number; 855 totalCount?: number; 856 message?: string; 857 backfillMappingId?: string; 858 backfillRequestId?: string; 859 lastUpdate: number; 860} 861 862let currentAppStatus: AppStatus = { 863 state: 'idle', 864 lastUpdate: Date.now(), 865}; 866 867let updateJobState: UpdateJobState = { 868 running: false, 869}; 870 871function signalSchedulerWake(): void { 872 schedulerWakeSignal += 1; 873} 874 875function requestImmediateSchedulerPass(): void { 876 lastCheckTime = 0; 877 nextCheckTime = Date.now() + 250; 878 signalSchedulerWake(); 879} 880 881if (allowedOrigins.size === 0) { 882 app.use(cors()); 883} else { 884 app.use( 885 cors({ 886 origin: (origin, callback) => { 887 if (!origin) { 888 callback(null, true); 889 return; 890 } 891 callback(null, allowedOrigins.has(origin)); 892 }, 893 }), 894 ); 895} 896app.use(express.json()); 897 898app.use( 899 express.static(staticAssetsDir, { 900 index: false, 901 }), 902); 903 904interface AuthenticatedUser { 905 id: string; 906 username?: string; 907 email?: string; 908 isAdmin: boolean; 909 permissions: UserPermissions; 910} 911 912interface MappingResponse extends Omit<AccountMapping, 'bskyPassword'> { 913 createdByLabel?: string; 914 createdByUser?: { 915 id: string; 916 username?: string; 917 email?: string; 918 role: UserRole; 919 }; 920} 921 922interface UserSummaryResponse { 923 id: string; 924 username?: string; 925 email?: string; 926 role: UserRole; 927 isAdmin: boolean; 928 permissions: UserPermissions; 929 createdAt: string; 930 updatedAt: string; 931 mappingCount: number; 932 activeMappingCount: number; 933 mappings: MappingResponse[]; 934} 935 936const normalizeEmail = (value: unknown): string | undefined => { 937 if (typeof value !== 'string') { 938 return undefined; 939 } 940 const normalized = value.trim().toLowerCase(); 941 return normalized.length > 0 ? normalized : undefined; 942}; 943 944const normalizeUsername = (value: unknown): string | undefined => { 945 if (typeof value !== 'string') { 946 return undefined; 947 } 948 const normalized = value.trim().replace(/^@/, '').toLowerCase(); 949 return normalized.length > 0 ? normalized : undefined; 950}; 951 952const normalizeOptionalString = (value: unknown): string | undefined => { 953 if (typeof value !== 'string') { 954 return undefined; 955 } 956 const normalized = value.trim(); 957 return normalized.length > 0 ? normalized : undefined; 958}; 959 960const getErrorMessage = (error: unknown, fallback = 'Request failed.'): string => { 961 if (axios.isAxiosError(error)) { 962 const apiError = error.response?.data as { error?: unknown } | undefined; 963 if (typeof apiError?.error === 'string' && apiError.error.length > 0) { 964 return apiError.error; 965 } 966 if (typeof error.message === 'string' && error.message.length > 0) { 967 return error.message; 968 } 969 } 970 if (error instanceof Error && error.message.length > 0) { 971 return error.message; 972 } 973 return fallback; 974}; 975 976const normalizeBoolean = (value: unknown, fallback: boolean): boolean => { 977 if (typeof value === 'boolean') { 978 return value; 979 } 980 return fallback; 981}; 982 983const EMAIL_LIKE_PATTERN = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/i; 984 985const getUserPublicLabel = (user: Pick<WebUser, 'id' | 'username'>): string => 986 user.username || `user-${user.id.slice(0, 8)}`; 987 988const getUserDisplayLabel = (user: Pick<WebUser, 'id' | 'username' | 'email'>): string => 989 user.username || user.email || `user-${user.id.slice(0, 8)}`; 990 991const getActorLabel = (actor: AuthenticatedUser): string => 992 actor.username || actor.email || `user-${actor.id.slice(0, 8)}`; 993 994const getActorPublicLabel = (actor: AuthenticatedUser): string => actor.username || `user-${actor.id.slice(0, 8)}`; 995 996const sanitizeLabelForRequester = (label: string | undefined, requester: AuthenticatedUser): string | undefined => { 997 if (!label) { 998 return undefined; 999 } 1000 if (requester.isAdmin) { 1001 return label; 1002 } 1003 return EMAIL_LIKE_PATTERN.test(label) ? 'private-user' : label; 1004}; 1005 1006const createUserLookupById = (config: AppConfig): Map<string, WebUser> => 1007 new Map(config.users.map((user) => [user.id, user])); 1008 1009const toAuthenticatedUser = (user: WebUser): AuthenticatedUser => ({ 1010 id: user.id, 1011 username: user.username, 1012 email: user.email, 1013 isAdmin: user.role === 'admin', 1014 permissions: 1015 user.role === 'admin' 1016 ? { ...ADMIN_USER_PERMISSIONS } 1017 : { 1018 ...getDefaultUserPermissions('user'), 1019 ...user.permissions, 1020 }, 1021}); 1022 1023const serializeAuthenticatedUser = (user: AuthenticatedUser) => ({ 1024 id: user.id, 1025 username: user.username, 1026 email: user.email, 1027 isAdmin: user.isAdmin, 1028 permissions: user.permissions, 1029}); 1030 1031const issueTokenForUser = (user: WebUser): string => 1032 jwt.sign( 1033 { 1034 userId: user.id, 1035 email: user.email, 1036 username: user.username, 1037 }, 1038 JWT_SECRET, 1039 { expiresIn: JWT_EXPIRES_IN }, 1040 ); 1041 1042const findUserByIdentifier = (config: AppConfig, identifier: string): WebUser | undefined => { 1043 const normalizedEmail = normalizeEmail(identifier); 1044 if (normalizedEmail) { 1045 const foundByEmail = config.users.find((user) => normalizeEmail(user.email) === normalizedEmail); 1046 if (foundByEmail) { 1047 return foundByEmail; 1048 } 1049 } 1050 1051 const normalizedUsername = normalizeUsername(identifier); 1052 if (!normalizedUsername) { 1053 return undefined; 1054 } 1055 return config.users.find((user) => normalizeUsername(user.username) === normalizedUsername); 1056}; 1057 1058const findUserFromTokenPayload = (config: AppConfig, payload: Record<string, unknown>): WebUser | undefined => { 1059 const tokenUserId = normalizeOptionalString(payload.userId) ?? normalizeOptionalString(payload.id); 1060 if (tokenUserId) { 1061 const byId = config.users.find((user) => user.id === tokenUserId); 1062 if (byId) { 1063 return byId; 1064 } 1065 } 1066 1067 const tokenEmail = normalizeEmail(payload.email); 1068 if (tokenEmail) { 1069 const byEmail = config.users.find((user) => normalizeEmail(user.email) === tokenEmail); 1070 if (byEmail) { 1071 return byEmail; 1072 } 1073 } 1074 1075 const tokenUsername = normalizeUsername(payload.username); 1076 if (tokenUsername) { 1077 const byUsername = config.users.find((user) => normalizeUsername(user.username) === tokenUsername); 1078 if (byUsername) { 1079 return byUsername; 1080 } 1081 } 1082 1083 return undefined; 1084}; 1085 1086const isActorAdmin = (user: AuthenticatedUser): boolean => user.isAdmin; 1087 1088const canViewAllMappings = (user: AuthenticatedUser): boolean => 1089 isActorAdmin(user) || user.permissions.viewAllMappings || user.permissions.manageAllMappings; 1090 1091const canManageAllMappings = (user: AuthenticatedUser): boolean => 1092 isActorAdmin(user) || user.permissions.manageAllMappings; 1093 1094const canManageOwnMappings = (user: AuthenticatedUser): boolean => 1095 isActorAdmin(user) || user.permissions.manageOwnMappings; 1096 1097const canManageGroups = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.manageGroups; 1098 1099const canQueueBackfills = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.queueBackfills; 1100 1101const canRunNow = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.runNow; 1102 1103const canManageMapping = (user: AuthenticatedUser, mapping: AccountMapping): boolean => { 1104 if (canManageAllMappings(user)) { 1105 return true; 1106 } 1107 if (!canManageOwnMappings(user)) { 1108 return false; 1109 } 1110 return mapping.createdByUserId === user.id; 1111}; 1112 1113const getVisibleMappings = (config: AppConfig, user: AuthenticatedUser): AccountMapping[] => { 1114 if (canViewAllMappings(user)) { 1115 return config.mappings; 1116 } 1117 1118 return config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 1119}; 1120 1121const getVisibleMappingIdSet = (config: AppConfig, user: AuthenticatedUser): Set<string> => 1122 new Set(getVisibleMappings(config, user).map((mapping) => mapping.id)); 1123 1124const getVisibleMappingIdentitySets = (config: AppConfig, user: AuthenticatedUser) => { 1125 const visible = getVisibleMappings(config, user); 1126 const twitterUsernames = new Set<string>(); 1127 const bskyIdentifiers = new Set<string>(); 1128 1129 for (const mapping of visible) { 1130 for (const username of mapping.twitterUsernames) { 1131 twitterUsernames.add(normalizeActor(username)); 1132 } 1133 bskyIdentifiers.add(normalizeActor(mapping.bskyIdentifier)); 1134 } 1135 1136 return { 1137 twitterUsernames, 1138 bskyIdentifiers, 1139 }; 1140}; 1141 1142const sanitizeMapping = ( 1143 mapping: AccountMapping, 1144 usersById: Map<string, WebUser>, 1145 requester: AuthenticatedUser, 1146): MappingResponse => { 1147 const { bskyPassword: _password, ...rest } = mapping; 1148 const createdBy = mapping.createdByUserId ? usersById.get(mapping.createdByUserId) : undefined; 1149 const ownerLabel = sanitizeLabelForRequester(mapping.owner, requester); 1150 1151 const response: MappingResponse = { 1152 ...rest, 1153 owner: ownerLabel, 1154 createdByLabel: createdBy 1155 ? requester.isAdmin 1156 ? getUserDisplayLabel(createdBy) 1157 : getUserPublicLabel(createdBy) 1158 : ownerLabel, 1159 }; 1160 1161 if (requester.isAdmin && createdBy) { 1162 response.createdByUser = { 1163 id: createdBy.id, 1164 username: createdBy.username, 1165 email: createdBy.email, 1166 role: createdBy.role, 1167 }; 1168 } 1169 1170 return response; 1171}; 1172 1173const parseTwitterUsernames = (value: unknown): string[] => { 1174 const seen = new Set<string>(); 1175 const usernames: string[] = []; 1176 const add = (candidate: unknown) => { 1177 if (typeof candidate !== 'string') { 1178 return; 1179 } 1180 const normalized = normalizeActor(candidate); 1181 if (!normalized || seen.has(normalized)) { 1182 return; 1183 } 1184 seen.add(normalized); 1185 usernames.push(normalized); 1186 }; 1187 1188 if (Array.isArray(value)) { 1189 for (const candidate of value) { 1190 add(candidate); 1191 } 1192 } else if (typeof value === 'string') { 1193 for (const candidate of value.split(',')) { 1194 add(candidate); 1195 } 1196 } 1197 1198 return usernames; 1199}; 1200 1201const parseMappingIds = (value: unknown): string[] => { 1202 if (!Array.isArray(value)) { 1203 return []; 1204 } 1205 1206 const seen = new Set<string>(); 1207 const ids: string[] = []; 1208 for (const candidate of value) { 1209 if (typeof candidate !== 'string') { 1210 continue; 1211 } 1212 const normalized = candidate.trim(); 1213 if (!normalized || seen.has(normalized)) { 1214 continue; 1215 } 1216 seen.add(normalized); 1217 ids.push(normalized); 1218 } 1219 1220 return ids; 1221}; 1222 1223const resolveProfileSyncSourceUsername = (args: { 1224 twitterUsernames: string[]; 1225 requestedSource?: unknown; 1226 fallbackSource?: string; 1227}): string | undefined => { 1228 const twitterUsernames = args.twitterUsernames.map(normalizeActor).filter((username) => username.length > 0); 1229 if (twitterUsernames.length === 0) { 1230 return undefined; 1231 } 1232 1233 const normalizedRequested = 1234 args.requestedSource !== undefined ? normalizeActor(String(args.requestedSource || '')) : undefined; 1235 const normalizedFallback = normalizeActor(args.fallbackSource || ''); 1236 1237 let resolved = normalizedRequested; 1238 if (!resolved && normalizedFallback && twitterUsernames.includes(normalizedFallback)) { 1239 resolved = normalizedFallback; 1240 } 1241 1242 if (resolved && twitterUsernames.includes(resolved)) { 1243 return resolved; 1244 } 1245 1246 return twitterUsernames[0]; 1247}; 1248 1249const getMappingMirrorSyncState = (mapping: AccountMapping) => ({ 1250 sourceUsername: mapping.profileSyncSourceUsername, 1251 mirroredDisplayName: mapping.lastMirroredDisplayName, 1252 mirroredDescription: mapping.lastMirroredDescription, 1253 avatarUrl: mapping.lastMirroredAvatarUrl, 1254 bannerUrl: mapping.lastMirroredBannerUrl, 1255}); 1256 1257const getAccessibleGroups = (config: AppConfig, user: AuthenticatedUser) => { 1258 const allGroups = Array.isArray(config.groups) 1259 ? config.groups.filter((group) => getNormalizedGroupKey(group.name) !== RESERVED_UNGROUPED_KEY) 1260 : []; 1261 1262 if (canViewAllMappings(user)) { 1263 return allGroups; 1264 } 1265 1266 const visibleMappings = getVisibleMappings(config, user); 1267 const allowedKeys = new Set<string>(); 1268 for (const mapping of visibleMappings) { 1269 const key = getNormalizedGroupKey(mapping.groupName); 1270 if (key && key !== RESERVED_UNGROUPED_KEY) { 1271 allowedKeys.add(key); 1272 } 1273 } 1274 1275 const merged = new Map<string, { name: string; emoji?: string }>(); 1276 for (const group of allGroups) { 1277 const key = getNormalizedGroupKey(group.name); 1278 if (!allowedKeys.has(key)) { 1279 continue; 1280 } 1281 merged.set(key, group); 1282 } 1283 1284 for (const mapping of visibleMappings) { 1285 const groupName = normalizeGroupName(mapping.groupName); 1286 if (!groupName || getNormalizedGroupKey(groupName) === RESERVED_UNGROUPED_KEY) { 1287 continue; 1288 } 1289 const key = getNormalizedGroupKey(groupName); 1290 if (!merged.has(key)) { 1291 merged.set(key, { 1292 name: groupName, 1293 ...(mapping.groupEmoji ? { emoji: mapping.groupEmoji } : {}), 1294 }); 1295 } 1296 } 1297 1298 return [...merged.values()]; 1299}; 1300 1301const parsePermissionsInput = (rawPermissions: unknown, role: UserRole): UserPermissions => { 1302 if (role === 'admin') { 1303 return { ...ADMIN_USER_PERMISSIONS }; 1304 } 1305 1306 const defaults = getDefaultUserPermissions(role); 1307 if (!rawPermissions || typeof rawPermissions !== 'object') { 1308 return defaults; 1309 } 1310 1311 const record = rawPermissions as Record<string, unknown>; 1312 return { 1313 viewAllMappings: normalizeBoolean(record.viewAllMappings, defaults.viewAllMappings), 1314 manageOwnMappings: normalizeBoolean(record.manageOwnMappings, defaults.manageOwnMappings), 1315 manageAllMappings: normalizeBoolean(record.manageAllMappings, defaults.manageAllMappings), 1316 manageGroups: normalizeBoolean(record.manageGroups, defaults.manageGroups), 1317 queueBackfills: normalizeBoolean(record.queueBackfills, defaults.queueBackfills), 1318 runNow: normalizeBoolean(record.runNow, defaults.runNow), 1319 }; 1320}; 1321 1322const validatePassword = (password: unknown): string | undefined => { 1323 if (typeof password !== 'string' || password.length < PASSWORD_MIN_LENGTH) { 1324 return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`; 1325 } 1326 return undefined; 1327}; 1328 1329const buildUserSummary = (config: AppConfig, requester: AuthenticatedUser): UserSummaryResponse[] => { 1330 const usersById = createUserLookupById(config); 1331 return config.users 1332 .map((user) => { 1333 const ownedMappings = config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 1334 const activeMappings = ownedMappings.filter((mapping) => mapping.enabled); 1335 return { 1336 id: user.id, 1337 username: user.username, 1338 email: user.email, 1339 role: user.role, 1340 isAdmin: user.role === 'admin', 1341 permissions: user.permissions, 1342 createdAt: user.createdAt, 1343 updatedAt: user.updatedAt, 1344 mappingCount: ownedMappings.length, 1345 activeMappingCount: activeMappings.length, 1346 mappings: ownedMappings.map((mapping) => sanitizeMapping(mapping, usersById, requester)), 1347 }; 1348 }) 1349 .sort((a, b) => { 1350 if (a.isAdmin && !b.isAdmin) { 1351 return -1; 1352 } 1353 if (!a.isAdmin && b.isAdmin) { 1354 return 1; 1355 } 1356 1357 const aLabel = (a.username || a.email || '').toLowerCase(); 1358 const bLabel = (b.username || b.email || '').toLowerCase(); 1359 return aLabel.localeCompare(bLabel); 1360 }); 1361}; 1362 1363const ensureUniqueIdentity = ( 1364 config: AppConfig, 1365 userId: string | undefined, 1366 username?: string, 1367 email?: string, 1368): string | null => { 1369 if (username) { 1370 const usernameTaken = config.users.some( 1371 (user) => user.id !== userId && normalizeUsername(user.username) === username, 1372 ); 1373 if (usernameTaken) { 1374 return 'Username already exists.'; 1375 } 1376 } 1377 if (email) { 1378 const emailTaken = config.users.some((user) => user.id !== userId && normalizeEmail(user.email) === email); 1379 if (emailTaken) { 1380 return 'Email already exists.'; 1381 } 1382 } 1383 return null; 1384}; 1385 1386const authenticateToken = (req: any, res: any, next: any) => { 1387 const authHeader = req.headers.authorization; 1388 const token = authHeader?.split(' ')[1]; 1389 1390 if (!token) { 1391 res.sendStatus(401); 1392 return; 1393 } 1394 1395 try { 1396 const decoded = jwt.verify(token, JWT_SECRET); 1397 if (!decoded || typeof decoded !== 'object') { 1398 res.sendStatus(403); 1399 return; 1400 } 1401 1402 const config = getConfig(); 1403 const user = findUserFromTokenPayload(config, decoded as Record<string, unknown>); 1404 if (!user) { 1405 res.sendStatus(401); 1406 return; 1407 } 1408 1409 req.user = toAuthenticatedUser(user); 1410 next(); 1411 } catch { 1412 res.sendStatus(403); 1413 } 1414}; 1415 1416const requireAdmin = (req: any, res: any, next: any) => { 1417 if (!req.user?.isAdmin) { 1418 res.status(403).json({ error: 'Admin access required' }); 1419 return; 1420 } 1421 next(); 1422}; 1423 1424function reconcileUpdateJobState() { 1425 if (!updateJobState.running) { 1426 return; 1427 } 1428 1429 if (isProcessAlive(updateJobState.pid)) { 1430 return; 1431 } 1432 1433 updateJobState = { 1434 ...updateJobState, 1435 running: false, 1436 finishedAt: updateJobState.finishedAt || Date.now(), 1437 exitCode: updateJobState.exitCode ?? null, 1438 signal: updateJobState.signal ?? null, 1439 }; 1440} 1441 1442function getUpdateStatusPayload(): UpdateStatusPayload { 1443 reconcileUpdateJobState(); 1444 return { 1445 ...updateJobState, 1446 logTail: readLogTail(updateJobState.logFile), 1447 }; 1448} 1449 1450function startUpdateJob(startedBy: string): { ok: true; state: UpdateStatusPayload } | { ok: false; message: string } { 1451 reconcileUpdateJobState(); 1452 1453 if (updateJobState.running) { 1454 return { ok: false, message: 'Update already running.' }; 1455 } 1456 1457 if (!fs.existsSync(UPDATE_SCRIPT_PATH)) { 1458 return { ok: false, message: 'update.sh not found in app root.' }; 1459 } 1460 1461 fs.mkdirSync(UPDATE_LOG_DIR, { recursive: true }); 1462 const logFile = path.join(UPDATE_LOG_DIR, `update-${Date.now()}.log`); 1463 const logFd = fs.openSync(logFile, 'a'); 1464 fs.writeSync(logFd, `[${new Date().toISOString()}] Update requested by ${startedBy}\n`); 1465 1466 try { 1467 const child = spawn('bash', [UPDATE_SCRIPT_PATH], { 1468 cwd: APP_ROOT_DIR, 1469 detached: true, 1470 stdio: ['ignore', logFd, logFd], 1471 env: process.env, 1472 }); 1473 1474 updateJobState = { 1475 running: true, 1476 pid: child.pid, 1477 startedAt: Date.now(), 1478 startedBy, 1479 logFile, 1480 finishedAt: undefined, 1481 exitCode: undefined, 1482 signal: undefined, 1483 }; 1484 1485 child.on('error', (error) => { 1486 fs.appendFileSync(logFile, `[${new Date().toISOString()}] Failed to launch updater: ${error.message}\n`); 1487 updateJobState = { 1488 ...updateJobState, 1489 running: false, 1490 finishedAt: Date.now(), 1491 exitCode: 1, 1492 }; 1493 }); 1494 1495 child.on('exit', (code, signal) => { 1496 const success = code === 0; 1497 fs.appendFileSync( 1498 logFile, 1499 `[${new Date().toISOString()}] Updater exited (${success ? 'success' : 'failure'}) code=${code ?? 'null'} signal=${signal ?? 'null'}\n`, 1500 ); 1501 updateJobState = { 1502 ...updateJobState, 1503 running: false, 1504 finishedAt: Date.now(), 1505 exitCode: code ?? null, 1506 signal: signal ?? null, 1507 }; 1508 }); 1509 1510 child.unref(); 1511 return { ok: true, state: getUpdateStatusPayload() }; 1512 } catch (error) { 1513 return { ok: false, message: `Failed to start update process: ${(error as Error).message}` }; 1514 } finally { 1515 fs.closeSync(logFd); 1516 } 1517} 1518 1519// --- Auth Routes --- 1520 1521app.get('/api/auth/bootstrap-status', (_req, res) => { 1522 const config = getConfig(); 1523 res.json({ bootstrapOpen: config.users.length === 0 }); 1524}); 1525 1526app.post('/api/register', authRateLimiter, async (req, res) => { 1527 const config = getConfig(); 1528 if (config.users.length > 0) { 1529 res.status(403).json({ error: 'Registration is disabled. Ask an admin to create your account.' }); 1530 return; 1531 } 1532 1533 const email = normalizeEmail(req.body?.email); 1534 const username = normalizeUsername(req.body?.username); 1535 const password = req.body?.password; 1536 1537 if (!email && !username) { 1538 res.status(400).json({ error: 'Username or email is required.' }); 1539 return; 1540 } 1541 1542 const passwordError = validatePassword(password); 1543 if (passwordError) { 1544 res.status(400).json({ error: passwordError }); 1545 return; 1546 } 1547 1548 const uniqueIdentityError = ensureUniqueIdentity(config, undefined, username, email); 1549 if (uniqueIdentityError) { 1550 res.status(400).json({ error: uniqueIdentityError }); 1551 return; 1552 } 1553 1554 const nowIso = new Date().toISOString(); 1555 const newUser: WebUser = { 1556 id: randomUUID(), 1557 username, 1558 email, 1559 passwordHash: await bcrypt.hash(password, 10), 1560 role: 'admin', 1561 permissions: { ...ADMIN_USER_PERMISSIONS }, 1562 createdAt: nowIso, 1563 updatedAt: nowIso, 1564 }; 1565 1566 config.users.push(newUser); 1567 1568 if (config.mappings.length > 0) { 1569 config.mappings = config.mappings.map((mapping) => ({ 1570 ...mapping, 1571 createdByUserId: mapping.createdByUserId || newUser.id, 1572 owner: mapping.owner || getUserPublicLabel(newUser), 1573 })); 1574 } 1575 1576 saveConfig(config); 1577 1578 res.json({ success: true }); 1579}); 1580 1581app.post('/api/login', authRateLimiter, async (req, res) => { 1582 const password = req.body?.password; 1583 const identifier = normalizeOptionalString(req.body?.identifier) ?? normalizeOptionalString(req.body?.email); 1584 if (!identifier || typeof password !== 'string') { 1585 res.status(400).json({ error: 'Username/email and password are required.' }); 1586 return; 1587 } 1588 1589 const config = getConfig(); 1590 const user = findUserByIdentifier(config, identifier); 1591 1592 if (!user || !(await bcrypt.compare(password, user.passwordHash))) { 1593 res.status(401).json({ error: 'Invalid credentials' }); 1594 return; 1595 } 1596 1597 const token = issueTokenForUser(user); 1598 res.json({ token, isAdmin: user.role === 'admin' }); 1599}); 1600 1601app.get('/api/me', authenticateToken, (req: any, res) => { 1602 res.json(serializeAuthenticatedUser(req.user)); 1603}); 1604 1605app.post('/api/me/change-email', authenticateToken, async (req: any, res) => { 1606 const config = getConfig(); 1607 const userIndex = config.users.findIndex((user) => user.id === req.user.id); 1608 const user = config.users[userIndex]; 1609 if (userIndex === -1 || !user) { 1610 res.status(404).json({ error: 'User not found.' }); 1611 return; 1612 } 1613 1614 const currentEmail = normalizeEmail(req.body?.currentEmail); 1615 const newEmail = normalizeEmail(req.body?.newEmail); 1616 const password = req.body?.password; 1617 if (!newEmail) { 1618 res.status(400).json({ error: 'A new email is required.' }); 1619 return; 1620 } 1621 if (typeof password !== 'string') { 1622 res.status(400).json({ error: 'Password is required.' }); 1623 return; 1624 } 1625 1626 const existingEmail = normalizeEmail(user.email); 1627 if (existingEmail && currentEmail !== existingEmail) { 1628 res.status(400).json({ error: 'Current email does not match.' }); 1629 return; 1630 } 1631 1632 if (!(await bcrypt.compare(password, user.passwordHash))) { 1633 res.status(401).json({ error: 'Password verification failed.' }); 1634 return; 1635 } 1636 1637 const uniqueIdentityError = ensureUniqueIdentity(config, user.id, normalizeUsername(user.username), newEmail); 1638 if (uniqueIdentityError) { 1639 res.status(400).json({ error: uniqueIdentityError }); 1640 return; 1641 } 1642 1643 const updatedUser: WebUser = { 1644 ...user, 1645 email: newEmail, 1646 updatedAt: new Date().toISOString(), 1647 }; 1648 config.users[userIndex] = updatedUser; 1649 saveConfig(config); 1650 1651 const token = issueTokenForUser(updatedUser); 1652 res.json({ 1653 success: true, 1654 token, 1655 me: serializeAuthenticatedUser(toAuthenticatedUser(updatedUser)), 1656 }); 1657}); 1658 1659app.post('/api/me/change-password', authenticateToken, async (req: any, res) => { 1660 const config = getConfig(); 1661 const userIndex = config.users.findIndex((user) => user.id === req.user.id); 1662 const user = config.users[userIndex]; 1663 if (userIndex === -1 || !user) { 1664 res.status(404).json({ error: 'User not found.' }); 1665 return; 1666 } 1667 1668 const currentPassword = req.body?.currentPassword; 1669 const newPassword = req.body?.newPassword; 1670 if (typeof currentPassword !== 'string') { 1671 res.status(400).json({ error: 'Current password is required.' }); 1672 return; 1673 } 1674 1675 const passwordError = validatePassword(newPassword); 1676 if (passwordError) { 1677 res.status(400).json({ error: passwordError }); 1678 return; 1679 } 1680 1681 if (!(await bcrypt.compare(currentPassword, user.passwordHash))) { 1682 res.status(401).json({ error: 'Current password is incorrect.' }); 1683 return; 1684 } 1685 1686 config.users[userIndex] = { 1687 ...user, 1688 passwordHash: await bcrypt.hash(newPassword, 10), 1689 updatedAt: new Date().toISOString(), 1690 }; 1691 saveConfig(config); 1692 res.json({ success: true }); 1693}); 1694 1695app.get('/api/admin/users', authenticateToken, requireAdmin, (req: any, res) => { 1696 const config = getConfig(); 1697 res.json(buildUserSummary(config, req.user)); 1698}); 1699 1700app.post('/api/admin/users', authenticateToken, requireAdmin, async (req: any, res) => { 1701 const config = getConfig(); 1702 const username = normalizeUsername(req.body?.username); 1703 const email = normalizeEmail(req.body?.email); 1704 const password = req.body?.password; 1705 const role: UserRole = req.body?.isAdmin ? 'admin' : 'user'; 1706 const permissions = parsePermissionsInput(req.body?.permissions, role); 1707 1708 if (!username && !email) { 1709 res.status(400).json({ error: 'Username or email is required.' }); 1710 return; 1711 } 1712 1713 const passwordError = validatePassword(password); 1714 if (passwordError) { 1715 res.status(400).json({ error: passwordError }); 1716 return; 1717 } 1718 1719 const uniqueIdentityError = ensureUniqueIdentity(config, undefined, username, email); 1720 if (uniqueIdentityError) { 1721 res.status(400).json({ error: uniqueIdentityError }); 1722 return; 1723 } 1724 1725 const nowIso = new Date().toISOString(); 1726 const newUser: WebUser = { 1727 id: randomUUID(), 1728 username, 1729 email, 1730 passwordHash: await bcrypt.hash(password, 10), 1731 role, 1732 permissions, 1733 createdAt: nowIso, 1734 updatedAt: nowIso, 1735 }; 1736 1737 config.users.push(newUser); 1738 saveConfig(config); 1739 1740 const summary = buildUserSummary(config, req.user).find((user) => user.id === newUser.id); 1741 res.json(summary || null); 1742}); 1743 1744app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req: any, res) => { 1745 const { id } = req.params; 1746 const config = getConfig(); 1747 const userIndex = config.users.findIndex((user) => user.id === id); 1748 const user = config.users[userIndex]; 1749 if (userIndex === -1 || !user) { 1750 res.status(404).json({ error: 'User not found.' }); 1751 return; 1752 } 1753 1754 const requestedRole: UserRole = 1755 req.body?.isAdmin === true ? 'admin' : req.body?.isAdmin === false ? 'user' : user.role; 1756 1757 if (user.id === req.user.id && requestedRole !== 'admin') { 1758 res.status(400).json({ error: 'You cannot remove your own admin access.' }); 1759 return; 1760 } 1761 1762 if (user.role === 'admin' && requestedRole !== 'admin') { 1763 const adminCount = config.users.filter((entry) => entry.role === 'admin').length; 1764 if (adminCount <= 1) { 1765 res.status(400).json({ error: 'At least one admin must remain.' }); 1766 return; 1767 } 1768 } 1769 1770 const username = 1771 req.body?.username !== undefined ? normalizeUsername(req.body?.username) : normalizeUsername(user.username); 1772 const email = req.body?.email !== undefined ? normalizeEmail(req.body?.email) : normalizeEmail(user.email); 1773 1774 if (!username && !email) { 1775 res.status(400).json({ error: 'User must keep at least a username or email.' }); 1776 return; 1777 } 1778 1779 const uniqueIdentityError = ensureUniqueIdentity(config, user.id, username, email); 1780 if (uniqueIdentityError) { 1781 res.status(400).json({ error: uniqueIdentityError }); 1782 return; 1783 } 1784 1785 const permissions = 1786 req.body?.permissions !== undefined || req.body?.isAdmin !== undefined 1787 ? parsePermissionsInput(req.body?.permissions, requestedRole) 1788 : requestedRole === 'admin' 1789 ? { ...ADMIN_USER_PERMISSIONS } 1790 : user.permissions; 1791 1792 config.users[userIndex] = { 1793 ...user, 1794 username, 1795 email, 1796 role: requestedRole, 1797 permissions, 1798 updatedAt: new Date().toISOString(), 1799 }; 1800 1801 saveConfig(config); 1802 const summary = buildUserSummary(config, req.user).find((entry) => entry.id === id); 1803 res.json(summary || null); 1804}); 1805 1806app.post('/api/admin/users/:id/reset-password', authenticateToken, requireAdmin, async (req, res) => { 1807 const { id } = req.params; 1808 const config = getConfig(); 1809 const userIndex = config.users.findIndex((user) => user.id === id); 1810 const user = config.users[userIndex]; 1811 if (userIndex === -1 || !user) { 1812 res.status(404).json({ error: 'User not found.' }); 1813 return; 1814 } 1815 1816 const newPassword = req.body?.newPassword; 1817 const passwordError = validatePassword(newPassword); 1818 if (passwordError) { 1819 res.status(400).json({ error: passwordError }); 1820 return; 1821 } 1822 1823 config.users[userIndex] = { 1824 ...user, 1825 passwordHash: await bcrypt.hash(newPassword, 10), 1826 updatedAt: new Date().toISOString(), 1827 }; 1828 saveConfig(config); 1829 res.json({ success: true }); 1830}); 1831 1832app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req: any, res) => { 1833 const { id } = req.params; 1834 const config = getConfig(); 1835 const userIndex = config.users.findIndex((user) => user.id === id); 1836 const user = config.users[userIndex]; 1837 1838 if (userIndex === -1 || !user) { 1839 res.status(404).json({ error: 'User not found.' }); 1840 return; 1841 } 1842 1843 if (user.id === req.user.id) { 1844 res.status(400).json({ error: 'You cannot delete your own account.' }); 1845 return; 1846 } 1847 1848 if (user.role === 'admin') { 1849 const adminCount = config.users.filter((entry) => entry.role === 'admin').length; 1850 if (adminCount <= 1) { 1851 res.status(400).json({ error: 'At least one admin must remain.' }); 1852 return; 1853 } 1854 } 1855 1856 const ownedMappings = config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 1857 const ownedMappingIds = new Set(ownedMappings.map((mapping) => mapping.id)); 1858 config.mappings = config.mappings.map((mapping) => 1859 mapping.createdByUserId === user.id 1860 ? { 1861 ...mapping, 1862 enabled: false, 1863 } 1864 : mapping, 1865 ); 1866 1867 config.users.splice(userIndex, 1); 1868 pendingBackfills = pendingBackfills.filter((backfill) => !ownedMappingIds.has(backfill.id)); 1869 saveConfig(config); 1870 1871 res.json({ 1872 success: true, 1873 disabledMappings: ownedMappings.length, 1874 }); 1875}); 1876 1877// --- Mapping Routes --- 1878 1879app.get('/api/mappings', authenticateToken, (req: any, res) => { 1880 const config = getConfig(); 1881 const usersById = createUserLookupById(config); 1882 const visibleMappings = getVisibleMappings(config, req.user); 1883 res.json(visibleMappings.map((mapping) => sanitizeMapping(mapping, usersById, req.user))); 1884}); 1885 1886app.get('/api/groups', authenticateToken, (req: any, res) => { 1887 const config = getConfig(); 1888 res.json(getAccessibleGroups(config, req.user)); 1889}); 1890 1891app.post('/api/groups', authenticateToken, (req: any, res) => { 1892 if (!canManageGroups(req.user)) { 1893 res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1894 return; 1895 } 1896 1897 const config = getConfig(); 1898 const normalizedName = normalizeGroupName(req.body?.name); 1899 const normalizedEmoji = normalizeGroupEmoji(req.body?.emoji); 1900 1901 if (!normalizedName) { 1902 res.status(400).json({ error: 'Group name is required.' }); 1903 return; 1904 } 1905 1906 if (getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) { 1907 res.status(400).json({ error: '"Ungrouped" is reserved for default behavior.' }); 1908 return; 1909 } 1910 1911 ensureGroupExists(config, normalizedName, normalizedEmoji); 1912 saveConfig(config); 1913 1914 const group = config.groups.find( 1915 (entry) => getNormalizedGroupKey(entry.name) === getNormalizedGroupKey(normalizedName), 1916 ); 1917 res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) }); 1918}); 1919 1920app.put('/api/groups/:groupKey', authenticateToken, (req: any, res) => { 1921 if (!canManageGroups(req.user)) { 1922 res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1923 return; 1924 } 1925 1926 const currentGroupKey = getNormalizedGroupKey(req.params.groupKey); 1927 if (!currentGroupKey || currentGroupKey === RESERVED_UNGROUPED_KEY) { 1928 res.status(400).json({ error: 'Invalid group key.' }); 1929 return; 1930 } 1931 1932 const requestedName = normalizeGroupName(req.body?.name); 1933 const requestedEmoji = normalizeGroupEmoji(req.body?.emoji); 1934 if (!requestedName) { 1935 res.status(400).json({ error: 'Group name is required.' }); 1936 return; 1937 } 1938 1939 const requestedGroupKey = getNormalizedGroupKey(requestedName); 1940 if (requestedGroupKey === RESERVED_UNGROUPED_KEY) { 1941 res.status(400).json({ error: '"Ungrouped" is reserved and cannot be edited.' }); 1942 return; 1943 } 1944 1945 const config = getConfig(); 1946 if (!Array.isArray(config.groups)) { 1947 config.groups = []; 1948 } 1949 1950 const groupIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === currentGroupKey); 1951 if (groupIndex === -1) { 1952 res.status(404).json({ error: 'Group not found.' }); 1953 return; 1954 } 1955 1956 const mergeIndex = config.groups.findIndex( 1957 (group, index) => index !== groupIndex && getNormalizedGroupKey(group.name) === requestedGroupKey, 1958 ); 1959 1960 let finalName = requestedName; 1961 let finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[groupIndex]?.emoji); 1962 if (mergeIndex !== -1) { 1963 finalName = normalizeGroupName(config.groups[mergeIndex]?.name) || requestedName; 1964 finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[mergeIndex]?.emoji) || finalEmoji; 1965 1966 config.groups[mergeIndex] = { 1967 name: finalName, 1968 ...(finalEmoji ? { emoji: finalEmoji } : {}), 1969 }; 1970 config.groups.splice(groupIndex, 1); 1971 } else { 1972 config.groups[groupIndex] = { 1973 name: finalName, 1974 ...(finalEmoji ? { emoji: finalEmoji } : {}), 1975 }; 1976 } 1977 1978 const keysToRewrite = new Set([currentGroupKey, requestedGroupKey]); 1979 config.mappings = config.mappings.map((mapping) => { 1980 const mappingGroupKey = getNormalizedGroupKey(mapping.groupName); 1981 if (!keysToRewrite.has(mappingGroupKey)) { 1982 return mapping; 1983 } 1984 return { 1985 ...mapping, 1986 groupName: finalName, 1987 groupEmoji: finalEmoji || undefined, 1988 }; 1989 }); 1990 1991 saveConfig(config); 1992 res.json({ 1993 name: finalName, 1994 ...(finalEmoji ? { emoji: finalEmoji } : {}), 1995 }); 1996}); 1997 1998app.delete('/api/groups/:groupKey', authenticateToken, (req: any, res) => { 1999 if (!canManageGroups(req.user)) { 2000 res.status(403).json({ error: 'You do not have permission to manage groups.' }); 2001 return; 2002 } 2003 2004 const groupKey = getNormalizedGroupKey(req.params.groupKey); 2005 if (!groupKey || groupKey === RESERVED_UNGROUPED_KEY) { 2006 res.status(400).json({ error: 'Invalid group key.' }); 2007 return; 2008 } 2009 2010 const config = getConfig(); 2011 if (!Array.isArray(config.groups)) { 2012 config.groups = []; 2013 } 2014 2015 const beforeCount = config.groups.length; 2016 config.groups = config.groups.filter((group) => getNormalizedGroupKey(group.name) !== groupKey); 2017 if (config.groups.length === beforeCount) { 2018 res.status(404).json({ error: 'Group not found.' }); 2019 return; 2020 } 2021 2022 let reassigned = 0; 2023 config.mappings = config.mappings.map((mapping) => { 2024 if (getNormalizedGroupKey(mapping.groupName) !== groupKey) { 2025 return mapping; 2026 } 2027 reassigned += 1; 2028 return { 2029 ...mapping, 2030 groupName: undefined, 2031 groupEmoji: undefined, 2032 }; 2033 }); 2034 2035 saveConfig(config); 2036 res.json({ success: true, reassignedCount: reassigned }); 2037}); 2038 2039app.post('/api/onboarding/twitter-profile', authenticateToken, async (req: any, res) => { 2040 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2041 res.status(403).json({ error: 'You do not have permission to create mappings.' }); 2042 return; 2043 } 2044 2045 const twitterUsername = normalizeActor(req.body?.twitterUsername || ''); 2046 if (!twitterUsername) { 2047 res.status(400).json({ error: 'Twitter username is required.' }); 2048 return; 2049 } 2050 2051 try { 2052 const profile = await fetchTwitterMirrorProfile(twitterUsername); 2053 res.json(profile); 2054 } catch (error) { 2055 res.status(400).json({ error: getErrorMessage(error, 'Failed to fetch Twitter profile metadata.') }); 2056 } 2057}); 2058 2059app.post('/api/onboarding/bsky-credentials', authenticateToken, async (req: any, res) => { 2060 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2061 res.status(403).json({ error: 'You do not have permission to create mappings.' }); 2062 return; 2063 } 2064 2065 const bskyIdentifier = normalizeOptionalString(req.body?.bskyIdentifier); 2066 const bskyPassword = normalizeOptionalString(req.body?.bskyPassword); 2067 const bskyServiceUrl = normalizeOptionalString(req.body?.bskyServiceUrl); 2068 2069 if (!bskyIdentifier || !bskyPassword) { 2070 res.status(400).json({ error: 'Bluesky identifier and app password are required.' }); 2071 return; 2072 } 2073 2074 try { 2075 const validation = await validateBlueskyCredentials({ 2076 bskyIdentifier, 2077 bskyPassword, 2078 bskyServiceUrl, 2079 }); 2080 res.json(validation); 2081 } catch (error) { 2082 res.status(400).json({ error: getErrorMessage(error, 'Failed to validate Bluesky credentials.') }); 2083 } 2084}); 2085 2086app.post('/api/mappings', authenticateToken, async (req: any, res) => { 2087 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2088 res.status(403).json({ error: 'You do not have permission to create mappings.' }); 2089 return; 2090 } 2091 2092 const config = getConfig(); 2093 const usersById = createUserLookupById(config); 2094 const twitterUsernames = parseTwitterUsernames(req.body?.twitterUsernames); 2095 if (twitterUsernames.length === 0) { 2096 res.status(400).json({ error: 'At least one Twitter username is required.' }); 2097 return; 2098 } 2099 2100 const bskyIdentifier = normalizeActor(req.body?.bskyIdentifier || ''); 2101 const bskyPassword = normalizeOptionalString(req.body?.bskyPassword); 2102 if (!bskyIdentifier || !bskyPassword) { 2103 res.status(400).json({ error: 'Bluesky identifier and app password are required.' }); 2104 return; 2105 } 2106 2107 let createdByUserId = req.user.id; 2108 const requestedCreatorId = normalizeOptionalString(req.body?.createdByUserId); 2109 if (requestedCreatorId && requestedCreatorId !== req.user.id) { 2110 if (!canManageAllMappings(req.user)) { 2111 res.status(403).json({ error: 'You cannot assign mappings to another user.' }); 2112 return; 2113 } 2114 if (!usersById.has(requestedCreatorId)) { 2115 res.status(400).json({ error: 'Selected account owner does not exist.' }); 2116 return; 2117 } 2118 createdByUserId = requestedCreatorId; 2119 } 2120 2121 const ownerUser = usersById.get(createdByUserId); 2122 const owner = 2123 normalizeOptionalString(req.body?.owner) || 2124 (ownerUser ? getUserPublicLabel(ownerUser) : getActorPublicLabel(req.user)); 2125 const normalizedGroupName = normalizeGroupName(req.body?.groupName); 2126 const normalizedGroupEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 2127 const profileSyncSourceUsername = resolveProfileSyncSourceUsername({ 2128 twitterUsernames, 2129 requestedSource: req.body?.profileSyncSourceUsername, 2130 }); 2131 2132 const newMapping: AccountMapping = { 2133 id: randomUUID(), 2134 twitterUsernames, 2135 bskyIdentifier, 2136 bskyPassword, 2137 bskyServiceUrl: normalizeOptionalString(req.body?.bskyServiceUrl) || 'https://bsky.social', 2138 enabled: true, 2139 owner, 2140 groupName: normalizedGroupName || undefined, 2141 groupEmoji: normalizedGroupEmoji || undefined, 2142 createdByUserId, 2143 profileSyncSourceUsername, 2144 hasBotLabel: false, 2145 }; 2146 2147 ensureGroupExists(config, normalizedGroupName, normalizedGroupEmoji); 2148 config.mappings.push(newMapping); 2149 saveConfig(config); 2150 2151 try { 2152 const labelResult = await ensureBlueskyBotSelfLabel({ 2153 bskyIdentifier: newMapping.bskyIdentifier, 2154 bskyPassword: newMapping.bskyPassword, 2155 bskyServiceUrl: newMapping.bskyServiceUrl, 2156 }); 2157 2158 if (labelResult.hasBotLabel && !newMapping.hasBotLabel) { 2159 newMapping.hasBotLabel = true; 2160 saveConfig(config); 2161 } 2162 2163 for (const key of [ 2164 normalizeActor(newMapping.bskyIdentifier), 2165 normalizeActor(labelResult.bsky.handle), 2166 normalizeActor(labelResult.bsky.did), 2167 ]) { 2168 if (key) { 2169 profileCache.delete(key); 2170 } 2171 } 2172 } catch (error) { 2173 console.warn( 2174 `[mapping:${newMapping.id}] Failed to apply Bluesky bot self-label during mapping creation: ${getErrorMessage(error)}`, 2175 ); 2176 } 2177 2178 res.json(sanitizeMapping(newMapping, createUserLookupById(config), req.user)); 2179}); 2180 2181app.put('/api/mappings/:id', authenticateToken, (req: any, res) => { 2182 const { id } = req.params; 2183 const config = getConfig(); 2184 const usersById = createUserLookupById(config); 2185 const index = config.mappings.findIndex((mapping) => mapping.id === id); 2186 const existingMapping = config.mappings[index]; 2187 2188 if (index === -1 || !existingMapping) { 2189 res.status(404).json({ error: 'Mapping not found' }); 2190 return; 2191 } 2192 2193 if (!canManageMapping(req.user, existingMapping)) { 2194 res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 2195 return; 2196 } 2197 2198 let twitterUsernames: string[] = existingMapping.twitterUsernames; 2199 if (req.body?.twitterUsernames !== undefined) { 2200 twitterUsernames = parseTwitterUsernames(req.body.twitterUsernames); 2201 if (twitterUsernames.length === 0) { 2202 res.status(400).json({ error: 'At least one Twitter username is required.' }); 2203 return; 2204 } 2205 } 2206 2207 let bskyIdentifier = existingMapping.bskyIdentifier; 2208 if (req.body?.bskyIdentifier !== undefined) { 2209 const normalizedIdentifier = normalizeActor(req.body?.bskyIdentifier); 2210 if (!normalizedIdentifier) { 2211 res.status(400).json({ error: 'Invalid Bluesky identifier.' }); 2212 return; 2213 } 2214 bskyIdentifier = normalizedIdentifier; 2215 } 2216 2217 let createdByUserId = existingMapping.createdByUserId || req.user.id; 2218 if (req.body?.createdByUserId !== undefined) { 2219 if (!canManageAllMappings(req.user)) { 2220 res.status(403).json({ error: 'You cannot reassign mapping ownership.' }); 2221 return; 2222 } 2223 2224 const requestedCreatorId = normalizeOptionalString(req.body?.createdByUserId); 2225 if (!requestedCreatorId || !usersById.has(requestedCreatorId)) { 2226 res.status(400).json({ error: 'Selected account owner does not exist.' }); 2227 return; 2228 } 2229 createdByUserId = requestedCreatorId; 2230 } 2231 2232 let nextGroupName = existingMapping.groupName; 2233 if (req.body?.groupName !== undefined) { 2234 const normalizedName = normalizeGroupName(req.body?.groupName); 2235 nextGroupName = normalizedName || undefined; 2236 } 2237 2238 let nextGroupEmoji = existingMapping.groupEmoji; 2239 if (req.body?.groupEmoji !== undefined) { 2240 const normalizedEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 2241 nextGroupEmoji = normalizedEmoji || undefined; 2242 } 2243 2244 const ownerUser = usersById.get(createdByUserId); 2245 const owner = 2246 req.body?.owner !== undefined 2247 ? normalizeOptionalString(req.body?.owner) || existingMapping.owner 2248 : existingMapping.owner || (ownerUser ? getUserPublicLabel(ownerUser) : undefined); 2249 2250 const profileSyncSourceUsername = resolveProfileSyncSourceUsername({ 2251 twitterUsernames, 2252 requestedSource: req.body?.profileSyncSourceUsername, 2253 fallbackSource: existingMapping.profileSyncSourceUsername, 2254 }); 2255 2256 const updatedMapping: AccountMapping = { 2257 ...existingMapping, 2258 twitterUsernames, 2259 bskyIdentifier, 2260 bskyPassword: normalizeOptionalString(req.body?.bskyPassword) || existingMapping.bskyPassword, 2261 bskyServiceUrl: normalizeOptionalString(req.body?.bskyServiceUrl) || existingMapping.bskyServiceUrl, 2262 owner, 2263 groupName: nextGroupName, 2264 groupEmoji: nextGroupEmoji, 2265 createdByUserId, 2266 profileSyncSourceUsername, 2267 }; 2268 2269 ensureGroupExists(config, nextGroupName, nextGroupEmoji); 2270 config.mappings[index] = updatedMapping; 2271 saveConfig(config); 2272 res.json(sanitizeMapping(updatedMapping, createUserLookupById(config), req.user)); 2273}); 2274 2275app.post('/api/mappings/:id/sync-profile-from-twitter', authenticateToken, async (req: any, res) => { 2276 const { id } = req.params; 2277 const config = getConfig(); 2278 const mappingIndex = config.mappings.findIndex((entry) => entry.id === id); 2279 const mapping = config.mappings[mappingIndex]; 2280 2281 if (mappingIndex === -1 || !mapping) { 2282 res.status(404).json({ error: 'Mapping not found' }); 2283 return; 2284 } 2285 2286 if (!canManageMapping(req.user, mapping)) { 2287 res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 2288 return; 2289 } 2290 2291 const sourceTwitterUsername = resolveProfileSyncSourceUsername({ 2292 twitterUsernames: mapping.twitterUsernames, 2293 requestedSource: req.body?.sourceTwitterUsername, 2294 fallbackSource: mapping.profileSyncSourceUsername, 2295 }); 2296 2297 if (!sourceTwitterUsername) { 2298 res.status(400).json({ error: 'Mapping has no Twitter source usernames.' }); 2299 return; 2300 } 2301 2302 try { 2303 const result = await syncBlueskyProfileFromTwitter({ 2304 twitterUsername: sourceTwitterUsername, 2305 bskyIdentifier: mapping.bskyIdentifier, 2306 bskyPassword: mapping.bskyPassword, 2307 bskyServiceUrl: mapping.bskyServiceUrl, 2308 previousSync: getMappingMirrorSyncState(mapping), 2309 }); 2310 2311 const updatedMapping = applyProfileMirrorSyncState(mapping, sourceTwitterUsername, result); 2312 config.mappings[mappingIndex] = updatedMapping; 2313 saveConfig(config); 2314 2315 for (const key of [ 2316 normalizeActor(updatedMapping.bskyIdentifier), 2317 normalizeActor(result.bsky.handle), 2318 normalizeActor(result.bsky.did), 2319 ]) { 2320 if (key) { 2321 profileCache.delete(key); 2322 } 2323 } 2324 2325 res.json({ 2326 success: true, 2327 sourceTwitterUsername, 2328 mapping: sanitizeMapping(updatedMapping, createUserLookupById(config), req.user), 2329 ...result, 2330 }); 2331 } catch (error) { 2332 res.status(400).json({ error: getErrorMessage(error, 'Failed to sync Bluesky profile from Twitter.') }); 2333 } 2334}); 2335 2336app.post('/api/mappings/:id/pull-twitter-bio', authenticateToken, async (req: any, res) => { 2337 const { id } = req.params; 2338 const config = getConfig(); 2339 const mappingIndex = config.mappings.findIndex((entry) => entry.id === id); 2340 const mapping = config.mappings[mappingIndex]; 2341 2342 if (mappingIndex === -1 || !mapping) { 2343 res.status(404).json({ error: 'Mapping not found' }); 2344 return; 2345 } 2346 2347 if (!canManageMapping(req.user, mapping)) { 2348 res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 2349 return; 2350 } 2351 2352 const sourceTwitterUsername = resolveProfileSyncSourceUsername({ 2353 twitterUsernames: mapping.twitterUsernames, 2354 requestedSource: req.body?.sourceTwitterUsername, 2355 fallbackSource: mapping.profileSyncSourceUsername, 2356 }); 2357 2358 if (!sourceTwitterUsername) { 2359 res.status(400).json({ error: 'Mapping has no Twitter source usernames.' }); 2360 return; 2361 } 2362 2363 try { 2364 const result = await syncBlueskyProfileFromTwitter({ 2365 twitterUsername: sourceTwitterUsername, 2366 bskyIdentifier: mapping.bskyIdentifier, 2367 bskyPassword: mapping.bskyPassword, 2368 bskyServiceUrl: mapping.bskyServiceUrl, 2369 previousSync: getMappingMirrorSyncState(mapping), 2370 syncDisplayName: false, 2371 syncDescription: true, 2372 syncAvatar: false, 2373 syncBanner: false, 2374 }); 2375 2376 const updatedMapping = applyProfileMirrorSyncState(mapping, sourceTwitterUsername, result); 2377 config.mappings[mappingIndex] = updatedMapping; 2378 saveConfig(config); 2379 2380 for (const key of [ 2381 normalizeActor(updatedMapping.bskyIdentifier), 2382 normalizeActor(result.bsky.handle), 2383 normalizeActor(result.bsky.did), 2384 ]) { 2385 if (key) { 2386 profileCache.delete(key); 2387 } 2388 } 2389 2390 res.json({ 2391 success: true, 2392 sourceTwitterUsername, 2393 mapping: sanitizeMapping(updatedMapping, createUserLookupById(config), req.user), 2394 ...result, 2395 }); 2396 } catch (error) { 2397 res.status(400).json({ error: getErrorMessage(error, 'Failed to pull Twitter bio.') }); 2398 } 2399}); 2400 2401app.post('/api/mappings/bot-label-all', authenticateToken, async (req: any, res) => { 2402 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2403 res.status(403).json({ error: 'You do not have permission to update mappings.' }); 2404 return; 2405 } 2406 2407 const config = getConfig(); 2408 const usersById = createUserLookupById(config); 2409 const manageableMappings = getVisibleMappings(config, req.user).filter((mapping) => canManageMapping(req.user, mapping)); 2410 const requestedIds = parseMappingIds(req.body?.mappingIds); 2411 const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; 2412 const targets = requestedIdSet 2413 ? manageableMappings.filter((mapping) => requestedIdSet.has(mapping.id)) 2414 : manageableMappings; 2415 2416 if (targets.length === 0) { 2417 res.status(400).json({ error: 'No manageable mappings available for bot label update.' }); 2418 return; 2419 } 2420 2421 let labeled = 0; 2422 let alreadyLabeled = 0; 2423 let failed = 0; 2424 let changed = false; 2425 const failedMappings: Array<{ id: string; bskyIdentifier: string; error: string }> = []; 2426 2427 for (const mapping of targets) { 2428 try { 2429 const result = await ensureBlueskyBotSelfLabel({ 2430 bskyIdentifier: mapping.bskyIdentifier, 2431 bskyPassword: mapping.bskyPassword, 2432 bskyServiceUrl: mapping.bskyServiceUrl, 2433 }); 2434 2435 if (result.updated) { 2436 labeled += 1; 2437 } else { 2438 alreadyLabeled += 1; 2439 } 2440 2441 if (!mapping.hasBotLabel) { 2442 mapping.hasBotLabel = true; 2443 changed = true; 2444 } 2445 2446 for (const key of [ 2447 normalizeActor(mapping.bskyIdentifier), 2448 normalizeActor(result.bsky.handle), 2449 normalizeActor(result.bsky.did), 2450 ]) { 2451 if (key) { 2452 profileCache.delete(key); 2453 } 2454 } 2455 } catch (error) { 2456 failed += 1; 2457 failedMappings.push({ 2458 id: mapping.id, 2459 bskyIdentifier: mapping.bskyIdentifier, 2460 error: getErrorMessage(error, 'Failed to update bot label.'), 2461 }); 2462 } 2463 } 2464 2465 if (changed) { 2466 saveConfig(config); 2467 } 2468 2469 res.json({ 2470 success: true, 2471 total: targets.length, 2472 labeled, 2473 alreadyLabeled, 2474 failed, 2475 failedMappings, 2476 mappings: targets.map((mapping) => sanitizeMapping(mapping, usersById, req.user)), 2477 }); 2478}); 2479 2480app.post('/api/mappings/append-bot-name-all', authenticateToken, async (req: any, res) => { 2481 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2482 res.status(403).json({ error: 'You do not have permission to update mappings.' }); 2483 return; 2484 } 2485 2486 const config = getConfig(); 2487 const usersById = createUserLookupById(config); 2488 const manageableMappings = getVisibleMappings(config, req.user).filter((mapping) => canManageMapping(req.user, mapping)); 2489 const requestedIds = parseMappingIds(req.body?.mappingIds); 2490 const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; 2491 const targets = requestedIdSet 2492 ? manageableMappings.filter((mapping) => requestedIdSet.has(mapping.id)) 2493 : manageableMappings; 2494 2495 if (targets.length === 0) { 2496 res.status(400).json({ error: 'No manageable mappings available for display-name update.' }); 2497 return; 2498 } 2499 2500 let appended = 0; 2501 let alreadyAppended = 0; 2502 let failed = 0; 2503 let changed = false; 2504 const failedMappings: Array<{ id: string; bskyIdentifier: string; error: string }> = []; 2505 2506 for (const mapping of targets) { 2507 try { 2508 const sourceTwitterUsername = resolveProfileSyncSourceUsername({ 2509 twitterUsernames: mapping.twitterUsernames, 2510 fallbackSource: mapping.profileSyncSourceUsername, 2511 }); 2512 if (!sourceTwitterUsername) { 2513 failed += 1; 2514 failedMappings.push({ 2515 id: mapping.id, 2516 bskyIdentifier: mapping.bskyIdentifier, 2517 error: 'Mapping has no Twitter source usernames.', 2518 }); 2519 continue; 2520 } 2521 2522 const result = await ensureBlueskyDisplayNameBotSuffix({ 2523 bskyIdentifier: mapping.bskyIdentifier, 2524 bskyPassword: mapping.bskyPassword, 2525 bskyServiceUrl: mapping.bskyServiceUrl, 2526 twitterUsername: sourceTwitterUsername, 2527 }); 2528 2529 if (result.updated) { 2530 appended += 1; 2531 } else { 2532 alreadyAppended += 1; 2533 } 2534 2535 if (mapping.profileSyncSourceUsername !== sourceTwitterUsername) { 2536 mapping.profileSyncSourceUsername = sourceTwitterUsername; 2537 changed = true; 2538 } 2539 2540 if (mapping.lastMirroredDisplayName !== result.displayName) { 2541 mapping.lastMirroredDisplayName = result.displayName; 2542 changed = true; 2543 } 2544 2545 for (const key of [ 2546 normalizeActor(mapping.bskyIdentifier), 2547 normalizeActor(result.bsky.handle), 2548 normalizeActor(result.bsky.did), 2549 ]) { 2550 if (key) { 2551 profileCache.delete(key); 2552 } 2553 } 2554 } catch (error) { 2555 failed += 1; 2556 failedMappings.push({ 2557 id: mapping.id, 2558 bskyIdentifier: mapping.bskyIdentifier, 2559 error: getErrorMessage(error, 'Failed to append display-name suffix.'), 2560 }); 2561 } 2562 } 2563 2564 if (changed) { 2565 saveConfig(config); 2566 } 2567 2568 res.json({ 2569 success: true, 2570 total: targets.length, 2571 appended, 2572 alreadyAppended, 2573 failed, 2574 failedMappings, 2575 mappings: targets.map((mapping) => sanitizeMapping(mapping, usersById, req.user)), 2576 }); 2577}); 2578 2579app.post('/api/mappings/:id/bridge-to-fediverse', authenticateToken, async (req: any, res) => { 2580 const { id } = req.params; 2581 const config = getConfig(); 2582 const mapping = config.mappings.find((entry) => entry.id === id); 2583 2584 if (!mapping) { 2585 res.status(404).json({ error: 'Mapping not found' }); 2586 return; 2587 } 2588 2589 if (!canManageMapping(req.user, mapping)) { 2590 res.status(403).json({ error: 'You do not have permission to bridge this mapping.' }); 2591 return; 2592 } 2593 2594 try { 2595 const result = await bridgeBlueskyAccountToFediverse({ 2596 bskyIdentifier: mapping.bskyIdentifier, 2597 bskyPassword: mapping.bskyPassword, 2598 bskyServiceUrl: mapping.bskyServiceUrl, 2599 }); 2600 2601 fediverseBridgeStatusCache.set(normalizeActor(mapping.bskyIdentifier), { 2602 value: { 2603 bridged: true, 2604 checkedAt: new Date().toISOString(), 2605 }, 2606 expiresAt: nowMs() + FEDIVERSE_BRIDGE_STATUS_CACHE_TTL_MS, 2607 }); 2608 2609 res.json({ success: true, ...result }); 2610 } catch (error) { 2611 res.status(400).json({ error: getErrorMessage(error, 'Failed to bridge account to the fediverse.') }); 2612 } 2613}); 2614 2615app.post('/api/mappings/fediverse-bridge-status', authenticateToken, async (req: any, res) => { 2616 const config = getConfig(); 2617 const visibleMappings = getVisibleMappings(config, req.user); 2618 const visibleMappingsById = new Map(visibleMappings.map((mapping) => [mapping.id, mapping] as const)); 2619 2620 const requestedIds = parseMappingIds(req.body?.mappingIds); 2621 const idsToCheck = (requestedIds.length > 0 ? requestedIds : visibleMappings.map((mapping) => mapping.id)) 2622 .filter((id) => visibleMappingsById.has(id)) 2623 .slice(0, 200); 2624 2625 if (idsToCheck.length === 0) { 2626 res.json({}); 2627 return; 2628 } 2629 2630 const actorByMappingId = new Map<string, string>(); 2631 const actorsToCheck: string[] = []; 2632 const statuses: Record<string, FediverseBridgeStatusView> = {}; 2633 2634 for (const id of idsToCheck) { 2635 const mapping = visibleMappingsById.get(id); 2636 if (!mapping) { 2637 statuses[id] = { 2638 bridged: false, 2639 checkedAt: new Date().toISOString(), 2640 error: 'Mapping not visible to current user.', 2641 }; 2642 continue; 2643 } 2644 2645 const actor = normalizeActor(mapping.bskyIdentifier); 2646 if (!actor) { 2647 statuses[id] = { 2648 bridged: false, 2649 checkedAt: new Date().toISOString(), 2650 error: 'Missing Bluesky identifier for mapping.', 2651 }; 2652 continue; 2653 } 2654 2655 actorByMappingId.set(id, actor); 2656 actorsToCheck.push(actor); 2657 } 2658 2659 const actorStatuses = await fetchFediverseBridgeStatusesByActor(actorsToCheck); 2660 2661 for (const [mappingId, actor] of actorByMappingId.entries()) { 2662 const actorStatus = actorStatuses[actor]; 2663 if (actorStatus) { 2664 statuses[mappingId] = actorStatus; 2665 continue; 2666 } 2667 2668 statuses[mappingId] = { 2669 bridged: false, 2670 checkedAt: new Date().toISOString(), 2671 error: 'Bridge status could not be determined for this account.', 2672 }; 2673 } 2674 2675 res.json(statuses); 2676}); 2677 2678app.delete('/api/mappings/:id', authenticateToken, (req: any, res) => { 2679 const { id } = req.params; 2680 const config = getConfig(); 2681 const mapping = config.mappings.find((entry) => entry.id === id); 2682 2683 if (!mapping) { 2684 res.status(404).json({ error: 'Mapping not found' }); 2685 return; 2686 } 2687 2688 if (!canManageMapping(req.user, mapping)) { 2689 res.status(403).json({ error: 'You do not have permission to delete this mapping.' }); 2690 return; 2691 } 2692 2693 config.mappings = config.mappings.filter((entry) => entry.id !== id); 2694 pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 2695 saveConfig(config); 2696 res.json({ success: true }); 2697}); 2698 2699app.delete('/api/mappings/:id/cache', authenticateToken, requireAdmin, (req, res) => { 2700 const { id } = req.params; 2701 const config = getConfig(); 2702 const mapping = config.mappings.find((m) => m.id === id); 2703 if (!mapping) { 2704 res.status(404).json({ error: 'Mapping not found' }); 2705 return; 2706 } 2707 2708 for (const username of mapping.twitterUsernames) { 2709 dbService.deleteTweetsByUsername(username); 2710 } 2711 2712 res.json({ success: true, message: 'Cache cleared for all associated accounts' }); 2713}); 2714 2715app.post('/api/mappings/:id/delete-all-posts', authenticateToken, requireAdmin, async (req, res) => { 2716 const { id } = req.params; 2717 const config = getConfig(); 2718 const mapping = config.mappings.find((m) => m.id === id); 2719 if (!mapping) { 2720 res.status(404).json({ error: 'Mapping not found' }); 2721 return; 2722 } 2723 2724 try { 2725 const deletedCount = await deleteAllPosts(id); 2726 2727 dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); 2728 2729 res.json({ 2730 success: true, 2731 message: `Deleted ${deletedCount} posts from ${mapping.bskyIdentifier} and cleared local cache.`, 2732 }); 2733 } catch (err) { 2734 console.error('Failed to delete all posts:', err); 2735 res.status(500).json({ error: (err as Error).message }); 2736 } 2737}); 2738 2739// --- Twitter Config Routes (Admin Only) --- 2740 2741app.get('/api/twitter-config', authenticateToken, requireAdmin, (_req, res) => { 2742 const config = getConfig(); 2743 res.json(config.twitter); 2744}); 2745 2746app.post('/api/twitter-config', authenticateToken, requireAdmin, (req, res) => { 2747 const { authToken, ct0, backupAuthToken, backupCt0 } = req.body; 2748 const config = getConfig(); 2749 config.twitter = { authToken, ct0, backupAuthToken, backupCt0 }; 2750 saveConfig(config); 2751 res.json({ success: true }); 2752}); 2753 2754app.get('/api/ai-config', authenticateToken, requireAdmin, (_req, res) => { 2755 const config = getConfig(); 2756 const aiConfig = config.ai || { 2757 provider: 'gemini', 2758 apiKey: config.geminiApiKey || '', 2759 }; 2760 res.json(aiConfig); 2761}); 2762 2763app.post('/api/ai-config', authenticateToken, requireAdmin, (req, res) => { 2764 const { provider, apiKey, model, baseUrl } = req.body; 2765 const config = getConfig(); 2766 2767 config.ai = { 2768 provider, 2769 apiKey, 2770 model: model || undefined, 2771 baseUrl: baseUrl || undefined, 2772 }; 2773 2774 delete config.geminiApiKey; 2775 2776 saveConfig(config); 2777 res.json({ success: true }); 2778}); 2779 2780// --- Status & Actions Routes --- 2781 2782app.get('/api/status', authenticateToken, (req: any, res) => { 2783 const config = getConfig(); 2784 const now = Date.now(); 2785 const nextRunMs = Math.max(0, nextCheckTime - now); 2786 const visibleMappingIds = getVisibleMappingIdSet(config, req.user); 2787 const scopedPendingBackfills = pendingBackfills 2788 .filter((backfill) => visibleMappingIds.has(backfill.id)) 2789 .sort((a, b) => a.sequence - b.sequence); 2790 2791 const scopedStatus = 2792 currentAppStatus.state === 'backfilling' && 2793 currentAppStatus.backfillMappingId && 2794 !visibleMappingIds.has(currentAppStatus.backfillMappingId) 2795 ? { 2796 state: 'idle', 2797 message: 'Idle', 2798 lastUpdate: currentAppStatus.lastUpdate, 2799 } 2800 : currentAppStatus; 2801 2802 res.json({ 2803 lastCheckTime, 2804 nextCheckTime, 2805 nextCheckMinutes: Math.ceil(nextRunMs / 60000), 2806 checkIntervalMinutes: config.checkIntervalMinutes, 2807 pendingBackfills: scopedPendingBackfills.map((backfill, index) => ({ 2808 ...backfill, 2809 position: index + 1, 2810 })), 2811 currentStatus: scopedStatus, 2812 }); 2813}); 2814 2815app.get('/api/version', authenticateToken, (_req, res) => { 2816 res.json(getRuntimeVersionInfo()); 2817}); 2818 2819app.get('/api/update-status', authenticateToken, requireAdmin, (_req, res) => { 2820 res.json(getUpdateStatusPayload()); 2821}); 2822 2823app.post('/api/update', authenticateToken, requireAdmin, (req: any, res) => { 2824 const startedBy = getActorLabel(req.user); 2825 const result = startUpdateJob(startedBy); 2826 if (!result.ok) { 2827 const message = result.message; 2828 const statusCode = message === 'Update already running.' ? 409 : 500; 2829 res.status(statusCode).json({ error: message }); 2830 return; 2831 } 2832 2833 res.json({ 2834 success: true, 2835 message: 'Update started. Service may restart automatically.', 2836 status: result.state, 2837 version: getRuntimeVersionInfo(), 2838 }); 2839}); 2840 2841app.post('/api/run-now', authenticateToken, (req: any, res) => { 2842 if (!canRunNow(req.user)) { 2843 res.status(403).json({ error: 'You do not have permission to run checks manually.' }); 2844 return; 2845 } 2846 2847 requestImmediateSchedulerPass(); 2848 res.json({ success: true, message: 'Check triggered' }); 2849}); 2850 2851app.post('/api/backfill/clear-all', authenticateToken, requireAdmin, (_req, res) => { 2852 pendingBackfills = []; 2853 updateAppStatus({ 2854 state: 'idle', 2855 message: 'All backfills cleared', 2856 backfillMappingId: undefined, 2857 backfillRequestId: undefined, 2858 }); 2859 signalSchedulerWake(); 2860 res.json({ success: true, message: 'All backfills cleared' }); 2861}); 2862 2863app.post('/api/backfill/:id', authenticateToken, (req: any, res) => { 2864 if (!canQueueBackfills(req.user)) { 2865 res.status(403).json({ error: 'You do not have permission to queue backfills.' }); 2866 return; 2867 } 2868 2869 const { id } = req.params; 2870 const { limit } = req.body; 2871 const config = getConfig(); 2872 const mapping = config.mappings.find((m) => m.id === id); 2873 2874 if (!mapping) { 2875 res.status(404).json({ error: 'Mapping not found' }); 2876 return; 2877 } 2878 2879 if (!canManageMapping(req.user, mapping)) { 2880 res.status(403).json({ error: 'You do not have access to this mapping.' }); 2881 return; 2882 } 2883 2884 if (!Array.isArray(mapping.twitterUsernames) || mapping.twitterUsernames.length === 0) { 2885 res.status(400).json({ error: 'Mapping has no Twitter source accounts configured.' }); 2886 return; 2887 } 2888 2889 const parsedLimit = Number(limit); 2890 const safeLimit = Number.isFinite(parsedLimit) ? Math.max(1, Math.min(parsedLimit, 200)) : undefined; 2891 const queuedAt = Date.now(); 2892 const sequence = backfillSequence++; 2893 const requestId = randomUUID(); 2894 pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 2895 pendingBackfills.push({ 2896 id, 2897 limit: safeLimit, 2898 queuedAt, 2899 sequence, 2900 requestId, 2901 }); 2902 pendingBackfills.sort((a, b) => a.sequence - b.sequence); 2903 signalSchedulerWake(); 2904 2905 res.json({ 2906 success: true, 2907 message: `Backfill queued for @${mapping.twitterUsernames.join(', ')}`, 2908 requestId, 2909 }); 2910}); 2911 2912app.delete('/api/backfill/:id', authenticateToken, (req: any, res) => { 2913 const { id } = req.params; 2914 const config = getConfig(); 2915 const mapping = config.mappings.find((entry) => entry.id === id); 2916 2917 if (!mapping) { 2918 res.status(404).json({ error: 'Mapping not found' }); 2919 return; 2920 } 2921 2922 if (!canManageMapping(req.user, mapping)) { 2923 res.status(403).json({ error: 'You do not have permission to update this queue entry.' }); 2924 return; 2925 } 2926 2927 pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 2928 signalSchedulerWake(); 2929 res.json({ success: true }); 2930}); 2931 2932// --- Config Management Routes --- 2933 2934app.get('/api/config/export', authenticateToken, requireAdmin, (_req, res) => { 2935 const config = getConfig(); 2936 const { users, ...cleanConfig } = config; 2937 2938 res.setHeader('Content-Type', 'application/json'); 2939 res.setHeader('Content-Disposition', 'attachment; filename=tweets-2-bsky-config.json'); 2940 res.json(cleanConfig); 2941}); 2942 2943app.post('/api/config/import', authenticateToken, requireAdmin, (req, res) => { 2944 try { 2945 const importData = req.body; 2946 const currentConfig = getConfig(); 2947 2948 if (!importData.mappings || !Array.isArray(importData.mappings)) { 2949 res.status(400).json({ error: 'Invalid config format: missing mappings array' }); 2950 return; 2951 } 2952 2953 const newConfig = { 2954 ...currentConfig, 2955 mappings: importData.mappings, 2956 groups: Array.isArray(importData.groups) ? importData.groups : currentConfig.groups, 2957 twitter: importData.twitter || currentConfig.twitter, 2958 ai: importData.ai || currentConfig.ai, 2959 checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes, 2960 }; 2961 2962 saveConfig(newConfig); 2963 res.json({ success: true, message: 'Configuration imported successfully' }); 2964 } catch (err) { 2965 console.error('Import failed:', err); 2966 res.status(500).json({ error: 'Failed to process import file' }); 2967 } 2968}); 2969 2970app.get('/api/recent-activity', authenticateToken, (req: any, res) => { 2971 const limitCandidate = req.query.limit ? Number(req.query.limit) : 50; 2972 const limit = Number.isFinite(limitCandidate) ? Math.max(1, Math.min(limitCandidate, 200)) : 50; 2973 const config = getConfig(); 2974 const visibleSets = getVisibleMappingIdentitySets(config, req.user); 2975 const scanLimit = canViewAllMappings(req.user) ? limit : Math.max(limit * 6, 150); 2976 2977 const tweets = dbService.getRecentProcessedTweets(scanLimit); 2978 const filtered = canViewAllMappings(req.user) 2979 ? tweets 2980 : tweets.filter( 2981 (tweet) => 2982 visibleSets.twitterUsernames.has(normalizeActor(tweet.twitter_username)) || 2983 visibleSets.bskyIdentifiers.has(normalizeActor(tweet.bsky_identifier)), 2984 ); 2985 2986 res.json(filtered.slice(0, limit)); 2987}); 2988 2989app.post('/api/bsky/profiles', authenticateToken, async (req, res) => { 2990 const actors = Array.isArray(req.body?.actors) 2991 ? req.body.actors.filter((actor: unknown) => typeof actor === 'string') 2992 : []; 2993 2994 if (actors.length === 0) { 2995 res.json({}); 2996 return; 2997 } 2998 2999 const limitedActors = actors.slice(0, 200); 3000 const profiles = await fetchProfilesByActor(limitedActors); 3001 res.json(profiles); 3002}); 3003 3004app.get('/api/posts/search', authenticateToken, (req: any, res) => { 3005 const query = typeof req.query.q === 'string' ? req.query.q : ''; 3006 if (!query.trim()) { 3007 res.json([]); 3008 return; 3009 } 3010 3011 const requestedLimit = req.query.limit ? Number(req.query.limit) : 80; 3012 const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 200)) : 80; 3013 const searchLimit = Math.min(200, Math.max(80, limit * 4)); 3014 const config = getConfig(); 3015 const visibleSets = getVisibleMappingIdentitySets(config, req.user); 3016 3017 const scopedRows = dbService 3018 .searchMigratedTweets(query, searchLimit) 3019 .filter( 3020 (row) => 3021 canViewAllMappings(req.user) || 3022 visibleSets.twitterUsernames.has(normalizeActor(row.twitter_username)) || 3023 visibleSets.bskyIdentifiers.has(normalizeActor(row.bsky_identifier)), 3024 ) 3025 .slice(0, limit); 3026 3027 const results = scopedRows.map<LocalPostSearchResult>((row) => ({ 3028 twitterId: row.twitter_id, 3029 twitterUsername: row.twitter_username, 3030 bskyIdentifier: row.bsky_identifier, 3031 tweetText: row.tweet_text, 3032 bskyUri: row.bsky_uri, 3033 bskyCid: row.bsky_cid, 3034 createdAt: row.created_at, 3035 postUrl: buildPostUrl(row.bsky_identifier, row.bsky_uri), 3036 twitterUrl: buildTwitterPostUrl(row.twitter_username, row.twitter_id), 3037 score: Number(row.score.toFixed(2)), 3038 })); 3039 3040 res.json(results); 3041}); 3042 3043app.get('/api/posts/enriched', authenticateToken, async (req: any, res) => { 3044 const requestedLimit = req.query.limit ? Number(req.query.limit) : 24; 3045 const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 80)) : 24; 3046 const config = getConfig(); 3047 const visibleSets = getVisibleMappingIdentitySets(config, req.user); 3048 3049 const recent = dbService.getRecentProcessedTweets(limit * 8); 3050 const migratedWithUri = recent.filter( 3051 (row) => 3052 row.status === 'migrated' && 3053 row.bsky_uri && 3054 (canViewAllMappings(req.user) || 3055 visibleSets.twitterUsernames.has(normalizeActor(row.twitter_username)) || 3056 visibleSets.bskyIdentifiers.has(normalizeActor(row.bsky_identifier))), 3057 ); 3058 3059 const deduped: ProcessedTweet[] = []; 3060 const seenUris = new Set<string>(); 3061 for (const row of migratedWithUri) { 3062 const uri = row.bsky_uri; 3063 if (!uri || seenUris.has(uri)) continue; 3064 seenUris.add(uri); 3065 deduped.push(row); 3066 if (deduped.length >= limit) break; 3067 } 3068 3069 const uris = deduped.map((row) => row.bsky_uri).filter((uri): uri is string => typeof uri === 'string'); 3070 const postViewsByUri = await fetchPostViewsByUri(uris); 3071 const enriched = deduped.map((row) => buildEnrichedPost(row, row.bsky_uri ? postViewsByUri.get(row.bsky_uri) : null)); 3072 3073 res.json(enriched); 3074}); 3075// Export for use by index.ts 3076export function updateLastCheckTime() { 3077 const config = getConfig(); 3078 lastCheckTime = Date.now(); 3079 nextCheckTime = lastCheckTime + (config.checkIntervalMinutes || 5) * 60 * 1000; 3080} 3081 3082export function updateAppStatus(status: Partial<AppStatus>) { 3083 currentAppStatus = { 3084 ...currentAppStatus, 3085 ...status, 3086 lastUpdate: Date.now(), 3087 }; 3088} 3089 3090export function getPendingBackfills(): PendingBackfill[] { 3091 return [...pendingBackfills].sort((a, b) => a.sequence - b.sequence); 3092} 3093 3094export function getNextCheckTime(): number { 3095 return nextCheckTime; 3096} 3097 3098export function getSchedulerWakeSignal(): number { 3099 return schedulerWakeSignal; 3100} 3101 3102export function triggerImmediateRun(): void { 3103 requestImmediateSchedulerPass(); 3104} 3105 3106export function clearBackfill(id: string, requestId?: string) { 3107 if (requestId) { 3108 pendingBackfills = pendingBackfills.filter((bid) => !(bid.id === id && bid.requestId === requestId)); 3109 return; 3110 } 3111 pendingBackfills = pendingBackfills.filter((bid) => bid.id !== id); 3112} 3113 3114// Serve the frontend for any other route (middleware approach for Express 5) 3115app.use((_req, res) => { 3116 res.setHeader('Cache-Control', 'no-store, max-age=0, must-revalidate'); 3117 res.setHeader('Pragma', 'no-cache'); 3118 res.setHeader('Expires', '0'); 3119 res.sendFile(path.join(staticAssetsDir, 'index.html')); 3120}); 3121 3122export function startServer() { 3123 app.listen(PORT, HOST as any, () => { 3124 console.log(`🚀 Web interface running at http://localhost:${PORT}`); 3125 if (HOST === '127.0.0.1' || HOST === '::1' || HOST === 'localhost') { 3126 console.log(`🔒 Bound to ${HOST} (local-only). Use Tailscale Serve or a reverse proxy for remote access.`); 3127 return; 3128 } 3129 console.log('📡 Accessible on your local network/Tailscale via your IP.'); 3130 }); 3131}