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.
at master 2149 lines 66 kB view raw
1import { execSync, spawn } from 'node:child_process'; 2import { 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 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'; 25 26const __filename = fileURLToPath(import.meta.url); 27const __dirname = path.dirname(__filename); 28 29const app = express(); 30const PORT = Number(process.env.PORT) || 3000; 31const HOST = (process.env.HOST || process.env.BIND_HOST || '0.0.0.0').trim() || '0.0.0.0'; 32const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret'; 33const APP_ROOT_DIR = path.join(__dirname, '..'); 34const WEB_DIST_DIR = path.join(APP_ROOT_DIR, 'web', 'dist'); 35const LEGACY_PUBLIC_DIR = path.join(APP_ROOT_DIR, 'public'); 36const PACKAGE_JSON_PATH = path.join(APP_ROOT_DIR, 'package.json'); 37const UPDATE_SCRIPT_PATH = path.join(APP_ROOT_DIR, 'update.sh'); 38const UPDATE_LOG_DIR = path.join(APP_ROOT_DIR, 'data'); 39const staticAssetsDir = fs.existsSync(path.join(WEB_DIST_DIR, 'index.html')) ? WEB_DIST_DIR : LEGACY_PUBLIC_DIR; 40const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app'; 41const POST_VIEW_CACHE_TTL_MS = 60_000; 42const PROFILE_CACHE_TTL_MS = 5 * 60_000; 43const RESERVED_UNGROUPED_KEY = 'ungrouped'; 44const SERVER_STARTED_AT = Date.now(); 45const PASSWORD_MIN_LENGTH = 8; 46 47interface CacheEntry<T> { 48 value: T; 49 expiresAt: number; 50} 51 52interface BskyProfileView { 53 did?: string; 54 handle?: string; 55 displayName?: string; 56 avatar?: string; 57} 58 59interface EnrichedPostMedia { 60 type: 'image' | 'video' | 'external'; 61 url?: string; 62 thumb?: string; 63 alt?: string; 64 width?: number; 65 height?: number; 66 title?: string; 67 description?: string; 68} 69 70interface EnrichedPost { 71 bskyUri: string; 72 bskyCid?: string; 73 bskyIdentifier: string; 74 twitterId: string; 75 twitterUsername: string; 76 twitterUrl?: string; 77 postUrl?: string; 78 createdAt?: string; 79 text: string; 80 facets: unknown[]; 81 author: { 82 did?: string; 83 handle: string; 84 displayName?: string; 85 avatar?: string; 86 }; 87 stats: { 88 likes: number; 89 reposts: number; 90 replies: number; 91 quotes: number; 92 engagement: number; 93 }; 94 media: EnrichedPostMedia[]; 95} 96 97interface LocalPostSearchResult { 98 twitterId: string; 99 twitterUsername: string; 100 bskyIdentifier: string; 101 tweetText?: string; 102 bskyUri?: string; 103 bskyCid?: string; 104 createdAt?: string; 105 postUrl?: string; 106 twitterUrl?: string; 107 score: number; 108} 109 110interface RuntimeVersionInfo { 111 version: string; 112 commit?: string; 113 branch?: string; 114 startedAt: number; 115} 116 117interface UpdateJobState { 118 running: boolean; 119 pid?: number; 120 startedAt?: number; 121 startedBy?: string; 122 finishedAt?: number; 123 exitCode?: number | null; 124 signal?: NodeJS.Signals | null; 125 logFile?: string; 126} 127 128interface UpdateStatusPayload { 129 running: boolean; 130 pid?: number; 131 startedAt?: number; 132 startedBy?: string; 133 finishedAt?: number; 134 exitCode?: number | null; 135 signal?: NodeJS.Signals | null; 136 logFile?: string; 137 logTail: string[]; 138} 139 140const postViewCache = new Map<string, CacheEntry<any>>(); 141const profileCache = new Map<string, CacheEntry<BskyProfileView>>(); 142 143function chunkArray<T>(items: T[], size: number): T[][] { 144 if (size <= 0) return [items]; 145 const chunks: T[][] = []; 146 for (let i = 0; i < items.length; i += size) { 147 chunks.push(items.slice(i, i + size)); 148 } 149 return chunks; 150} 151 152function nowMs() { 153 return Date.now(); 154} 155 156function buildPostUrl(identifier: string, uri?: string): string | undefined { 157 if (!uri) return undefined; 158 const rkey = uri.split('/').filter(Boolean).pop(); 159 if (!rkey) return undefined; 160 return `https://bsky.app/profile/${identifier}/post/${rkey}`; 161} 162 163function buildTwitterPostUrl(username: string, twitterId: string): string | undefined { 164 if (!username || !twitterId) return undefined; 165 return `https://x.com/${normalizeActor(username)}/status/${twitterId}`; 166} 167 168function normalizeActor(actor: string): string { 169 return actor.trim().replace(/^@/, '').toLowerCase(); 170} 171 172function normalizeGroupName(value: unknown): string { 173 return typeof value === 'string' ? value.trim() : ''; 174} 175 176function normalizeGroupEmoji(value: unknown): string { 177 return typeof value === 'string' ? value.trim() : ''; 178} 179 180function getNormalizedGroupKey(value: unknown): string { 181 return normalizeGroupName(value).toLowerCase(); 182} 183 184function ensureGroupExists(config: AppConfig, name?: string, emoji?: string) { 185 const normalizedName = normalizeGroupName(name); 186 if (!normalizedName || getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) return; 187 188 if (!Array.isArray(config.groups)) { 189 config.groups = []; 190 } 191 192 const existingIndex = config.groups.findIndex( 193 (group) => getNormalizedGroupKey(group.name) === getNormalizedGroupKey(normalizedName), 194 ); 195 const normalizedEmoji = normalizeGroupEmoji(emoji); 196 197 if (existingIndex === -1) { 198 config.groups.push({ 199 name: normalizedName, 200 ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}), 201 }); 202 return; 203 } 204 205 if (normalizedEmoji) { 206 const existingGroupName = normalizeGroupName(config.groups[existingIndex]?.name) || normalizedName; 207 config.groups[existingIndex] = { 208 name: existingGroupName, 209 emoji: normalizedEmoji, 210 }; 211 } 212} 213 214function safeExec(command: string, cwd = APP_ROOT_DIR): string | undefined { 215 try { 216 return execSync(command, { 217 cwd, 218 stdio: ['ignore', 'pipe', 'ignore'], 219 encoding: 'utf8', 220 }).trim(); 221 } catch { 222 return undefined; 223 } 224} 225 226function getRuntimeVersionInfo(): RuntimeVersionInfo { 227 let version = 'unknown'; 228 try { 229 const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')); 230 if (typeof pkg?.version === 'string' && pkg.version.trim().length > 0) { 231 version = pkg.version.trim(); 232 } 233 } catch { 234 // Ignore parse/read failures and keep fallback. 235 } 236 237 return { 238 version, 239 commit: safeExec('git rev-parse --short HEAD'), 240 branch: safeExec('git rev-parse --abbrev-ref HEAD'), 241 startedAt: SERVER_STARTED_AT, 242 }; 243} 244 245function isProcessAlive(pid?: number): boolean { 246 if (!pid || pid <= 0) return false; 247 try { 248 process.kill(pid, 0); 249 return true; 250 } catch { 251 return false; 252 } 253} 254 255function readLogTail(logFile?: string, maxLines = 30): string[] { 256 if (!logFile || !fs.existsSync(logFile)) { 257 return []; 258 } 259 260 try { 261 const raw = fs.readFileSync(logFile, 'utf8'); 262 const lines = raw.split(/\r?\n/).filter((line) => line.length > 0); 263 return lines.slice(-maxLines); 264 } catch { 265 return []; 266 } 267} 268 269function extractMediaFromEmbed(embed: any): EnrichedPostMedia[] { 270 if (!embed || typeof embed !== 'object') { 271 return []; 272 } 273 274 const type = embed.$type; 275 if (type === 'app.bsky.embed.images#view') { 276 const images = Array.isArray(embed.images) ? embed.images : []; 277 return images.map((image: any) => ({ 278 type: 'image' as const, 279 url: typeof image.fullsize === 'string' ? image.fullsize : undefined, 280 thumb: typeof image.thumb === 'string' ? image.thumb : undefined, 281 alt: typeof image.alt === 'string' ? image.alt : undefined, 282 width: typeof image.aspectRatio?.width === 'number' ? image.aspectRatio.width : undefined, 283 height: typeof image.aspectRatio?.height === 'number' ? image.aspectRatio.height : undefined, 284 })); 285 } 286 287 if (type === 'app.bsky.embed.video#view') { 288 return [ 289 { 290 type: 'video', 291 url: typeof embed.playlist === 'string' ? embed.playlist : undefined, 292 thumb: typeof embed.thumbnail === 'string' ? embed.thumbnail : undefined, 293 alt: typeof embed.alt === 'string' ? embed.alt : undefined, 294 width: typeof embed.aspectRatio?.width === 'number' ? embed.aspectRatio.width : undefined, 295 height: typeof embed.aspectRatio?.height === 'number' ? embed.aspectRatio.height : undefined, 296 }, 297 ]; 298 } 299 300 if (type === 'app.bsky.embed.external#view') { 301 const external = embed.external || {}; 302 return [ 303 { 304 type: 'external', 305 url: typeof external.uri === 'string' ? external.uri : undefined, 306 thumb: typeof external.thumb === 'string' ? external.thumb : undefined, 307 title: typeof external.title === 'string' ? external.title : undefined, 308 description: typeof external.description === 'string' ? external.description : undefined, 309 }, 310 ]; 311 } 312 313 if (type === 'app.bsky.embed.recordWithMedia#view') { 314 return extractMediaFromEmbed(embed.media); 315 } 316 317 return []; 318} 319 320async function fetchPostViewsByUri(uris: string[]): Promise<Map<string, any>> { 321 const result = new Map<string, any>(); 322 const uniqueUris = [...new Set(uris.filter((uri) => typeof uri === 'string' && uri.length > 0))]; 323 const pendingUris: string[] = []; 324 325 for (const uri of uniqueUris) { 326 const cached = postViewCache.get(uri); 327 if (cached && cached.expiresAt > nowMs()) { 328 result.set(uri, cached.value); 329 continue; 330 } 331 pendingUris.push(uri); 332 } 333 334 for (const chunk of chunkArray(pendingUris, 25)) { 335 if (chunk.length === 0) continue; 336 const params = new URLSearchParams(); 337 for (const uri of chunk) params.append('uris', uri); 338 339 try { 340 const response = await axios.get(`${BSKY_APPVIEW_URL}/xrpc/app.bsky.feed.getPosts?${params.toString()}`, { 341 timeout: 12_000, 342 }); 343 const posts = Array.isArray(response.data?.posts) ? response.data.posts : []; 344 for (const post of posts) { 345 const uri = typeof post?.uri === 'string' ? post.uri : undefined; 346 if (!uri) continue; 347 postViewCache.set(uri, { 348 value: post, 349 expiresAt: nowMs() + POST_VIEW_CACHE_TTL_MS, 350 }); 351 result.set(uri, post); 352 } 353 } catch (error) { 354 console.warn('Failed to fetch post views from Bluesky appview:', error); 355 } 356 } 357 358 return result; 359} 360 361async function fetchProfilesByActor(actors: string[]): Promise<Record<string, BskyProfileView>> { 362 const uniqueActors = [...new Set(actors.map(normalizeActor).filter((actor) => actor.length > 0))]; 363 const result: Record<string, BskyProfileView> = {}; 364 const pendingActors: string[] = []; 365 366 for (const actor of uniqueActors) { 367 const cached = profileCache.get(actor); 368 if (cached && cached.expiresAt > nowMs()) { 369 result[actor] = cached.value; 370 continue; 371 } 372 pendingActors.push(actor); 373 } 374 375 for (const chunk of chunkArray(pendingActors, 25)) { 376 if (chunk.length === 0) continue; 377 const params = new URLSearchParams(); 378 for (const actor of chunk) params.append('actors', actor); 379 380 try { 381 const response = await axios.get(`${BSKY_APPVIEW_URL}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`, { 382 timeout: 12_000, 383 }); 384 const profiles = Array.isArray(response.data?.profiles) ? response.data.profiles : []; 385 for (const profile of profiles) { 386 const view: BskyProfileView = { 387 did: typeof profile?.did === 'string' ? profile.did : undefined, 388 handle: typeof profile?.handle === 'string' ? profile.handle : undefined, 389 displayName: typeof profile?.displayName === 'string' ? profile.displayName : undefined, 390 avatar: typeof profile?.avatar === 'string' ? profile.avatar : undefined, 391 }; 392 393 const keys = [ 394 typeof view.handle === 'string' ? normalizeActor(view.handle) : '', 395 typeof view.did === 'string' ? normalizeActor(view.did) : '', 396 ].filter((key) => key.length > 0); 397 398 for (const key of keys) { 399 profileCache.set(key, { value: view, expiresAt: nowMs() + PROFILE_CACHE_TTL_MS }); 400 result[key] = view; 401 } 402 } 403 } catch (error) { 404 console.warn('Failed to fetch profiles from Bluesky appview:', error); 405 } 406 } 407 408 for (const actor of uniqueActors) { 409 const cached = profileCache.get(actor); 410 if (cached && cached.expiresAt > nowMs()) { 411 result[actor] = cached.value; 412 } 413 } 414 415 return result; 416} 417 418function buildEnrichedPost(activity: ProcessedTweet, postView: any): EnrichedPost { 419 const record = postView?.record || {}; 420 const author = postView?.author || {}; 421 const likes = Number(postView?.likeCount) || 0; 422 const reposts = Number(postView?.repostCount) || 0; 423 const replies = Number(postView?.replyCount) || 0; 424 const quotes = Number(postView?.quoteCount) || 0; 425 426 const identifier = 427 (typeof activity.bsky_identifier === 'string' && activity.bsky_identifier.length > 0 428 ? activity.bsky_identifier 429 : typeof author.handle === 'string' 430 ? author.handle 431 : 'unknown') || 'unknown'; 432 433 return { 434 bskyUri: activity.bsky_uri || '', 435 bskyCid: typeof postView?.cid === 'string' ? postView.cid : activity.bsky_cid, 436 bskyIdentifier: identifier, 437 twitterId: activity.twitter_id, 438 twitterUsername: activity.twitter_username, 439 twitterUrl: buildTwitterPostUrl(activity.twitter_username, activity.twitter_id), 440 postUrl: buildPostUrl(identifier, activity.bsky_uri), 441 createdAt: 442 (typeof record.createdAt === 'string' ? record.createdAt : undefined) || 443 activity.created_at || 444 (typeof postView?.indexedAt === 'string' ? postView.indexedAt : undefined), 445 text: 446 (typeof record.text === 'string' ? record.text : undefined) || 447 activity.tweet_text || 448 `Tweet ID: ${activity.twitter_id}`, 449 facets: Array.isArray(record.facets) ? record.facets : [], 450 author: { 451 did: typeof author.did === 'string' ? author.did : undefined, 452 handle: typeof author.handle === 'string' && author.handle.length > 0 ? author.handle : activity.bsky_identifier, 453 displayName: typeof author.displayName === 'string' ? author.displayName : undefined, 454 avatar: typeof author.avatar === 'string' ? author.avatar : undefined, 455 }, 456 stats: { 457 likes, 458 reposts, 459 replies, 460 quotes, 461 engagement: likes + reposts + replies + quotes, 462 }, 463 media: extractMediaFromEmbed(postView?.embed), 464 }; 465} 466 467// In-memory state for triggers and scheduling 468let lastCheckTime = Date.now(); 469let nextCheckTime = Date.now() + (getConfig().checkIntervalMinutes || 5) * 60 * 1000; 470export interface PendingBackfill { 471 id: string; 472 limit?: number; 473 queuedAt: number; 474 sequence: number; 475 requestId: string; 476} 477let pendingBackfills: PendingBackfill[] = []; 478let backfillSequence = 0; 479 480interface AppStatus { 481 state: 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 482 currentAccount?: string; 483 processedCount?: number; 484 totalCount?: number; 485 message?: string; 486 backfillMappingId?: string; 487 backfillRequestId?: string; 488 lastUpdate: number; 489} 490 491let currentAppStatus: AppStatus = { 492 state: 'idle', 493 lastUpdate: Date.now(), 494}; 495 496let updateJobState: UpdateJobState = { 497 running: false, 498}; 499 500app.use(cors()); 501app.use(express.json()); 502 503app.use(express.static(staticAssetsDir)); 504 505interface AuthenticatedUser { 506 id: string; 507 username?: string; 508 email?: string; 509 isAdmin: boolean; 510 permissions: UserPermissions; 511} 512 513interface MappingResponse extends Omit<AccountMapping, 'bskyPassword'> { 514 createdByLabel?: string; 515 createdByUser?: { 516 id: string; 517 username?: string; 518 email?: string; 519 role: UserRole; 520 }; 521} 522 523interface UserSummaryResponse { 524 id: string; 525 username?: string; 526 email?: string; 527 role: UserRole; 528 isAdmin: boolean; 529 permissions: UserPermissions; 530 createdAt: string; 531 updatedAt: string; 532 mappingCount: number; 533 activeMappingCount: number; 534 mappings: MappingResponse[]; 535} 536 537const normalizeEmail = (value: unknown): string | undefined => { 538 if (typeof value !== 'string') { 539 return undefined; 540 } 541 const normalized = value.trim().toLowerCase(); 542 return normalized.length > 0 ? normalized : undefined; 543}; 544 545const normalizeUsername = (value: unknown): string | undefined => { 546 if (typeof value !== 'string') { 547 return undefined; 548 } 549 const normalized = value.trim().replace(/^@/, '').toLowerCase(); 550 return normalized.length > 0 ? normalized : undefined; 551}; 552 553const normalizeOptionalString = (value: unknown): string | undefined => { 554 if (typeof value !== 'string') { 555 return undefined; 556 } 557 const normalized = value.trim(); 558 return normalized.length > 0 ? normalized : undefined; 559}; 560 561const normalizeBoolean = (value: unknown, fallback: boolean): boolean => { 562 if (typeof value === 'boolean') { 563 return value; 564 } 565 return fallback; 566}; 567 568const EMAIL_LIKE_PATTERN = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/i; 569 570const getUserPublicLabel = (user: Pick<WebUser, 'id' | 'username'>): string => 571 user.username || `user-${user.id.slice(0, 8)}`; 572 573const getUserDisplayLabel = (user: Pick<WebUser, 'id' | 'username' | 'email'>): string => 574 user.username || user.email || `user-${user.id.slice(0, 8)}`; 575 576const getActorLabel = (actor: AuthenticatedUser): string => actor.username || actor.email || `user-${actor.id.slice(0, 8)}`; 577 578const getActorPublicLabel = (actor: AuthenticatedUser): string => actor.username || `user-${actor.id.slice(0, 8)}`; 579 580const sanitizeLabelForRequester = (label: string | undefined, requester: AuthenticatedUser): string | undefined => { 581 if (!label) { 582 return undefined; 583 } 584 if (requester.isAdmin) { 585 return label; 586 } 587 return EMAIL_LIKE_PATTERN.test(label) ? 'private-user' : label; 588}; 589 590const createUserLookupById = (config: AppConfig): Map<string, WebUser> => 591 new Map(config.users.map((user) => [user.id, user])); 592 593const toAuthenticatedUser = (user: WebUser): AuthenticatedUser => ({ 594 id: user.id, 595 username: user.username, 596 email: user.email, 597 isAdmin: user.role === 'admin', 598 permissions: 599 user.role === 'admin' 600 ? { ...ADMIN_USER_PERMISSIONS } 601 : { 602 ...getDefaultUserPermissions('user'), 603 ...user.permissions, 604 }, 605}); 606 607const serializeAuthenticatedUser = (user: AuthenticatedUser) => ({ 608 id: user.id, 609 username: user.username, 610 email: user.email, 611 isAdmin: user.isAdmin, 612 permissions: user.permissions, 613}); 614 615const issueTokenForUser = (user: WebUser): string => 616 jwt.sign( 617 { 618 userId: user.id, 619 email: user.email, 620 username: user.username, 621 }, 622 JWT_SECRET, 623 { expiresIn: '24h' }, 624 ); 625 626const findUserByIdentifier = (config: AppConfig, identifier: string): WebUser | undefined => { 627 const normalizedEmail = normalizeEmail(identifier); 628 if (normalizedEmail) { 629 const foundByEmail = config.users.find((user) => normalizeEmail(user.email) === normalizedEmail); 630 if (foundByEmail) { 631 return foundByEmail; 632 } 633 } 634 635 const normalizedUsername = normalizeUsername(identifier); 636 if (!normalizedUsername) { 637 return undefined; 638 } 639 return config.users.find((user) => normalizeUsername(user.username) === normalizedUsername); 640}; 641 642const findUserFromTokenPayload = (config: AppConfig, payload: Record<string, unknown>): WebUser | undefined => { 643 const tokenUserId = normalizeOptionalString(payload.userId) ?? normalizeOptionalString(payload.id); 644 if (tokenUserId) { 645 const byId = config.users.find((user) => user.id === tokenUserId); 646 if (byId) { 647 return byId; 648 } 649 } 650 651 const tokenEmail = normalizeEmail(payload.email); 652 if (tokenEmail) { 653 const byEmail = config.users.find((user) => normalizeEmail(user.email) === tokenEmail); 654 if (byEmail) { 655 return byEmail; 656 } 657 } 658 659 const tokenUsername = normalizeUsername(payload.username); 660 if (tokenUsername) { 661 const byUsername = config.users.find((user) => normalizeUsername(user.username) === tokenUsername); 662 if (byUsername) { 663 return byUsername; 664 } 665 } 666 667 return undefined; 668}; 669 670const isActorAdmin = (user: AuthenticatedUser): boolean => user.isAdmin; 671 672const canViewAllMappings = (user: AuthenticatedUser): boolean => 673 isActorAdmin(user) || user.permissions.viewAllMappings || user.permissions.manageAllMappings; 674 675const canManageAllMappings = (user: AuthenticatedUser): boolean => 676 isActorAdmin(user) || user.permissions.manageAllMappings; 677 678const canManageOwnMappings = (user: AuthenticatedUser): boolean => 679 isActorAdmin(user) || user.permissions.manageOwnMappings; 680 681const canManageGroups = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.manageGroups; 682 683const canQueueBackfills = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.queueBackfills; 684 685const canRunNow = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.runNow; 686 687const canManageMapping = (user: AuthenticatedUser, mapping: AccountMapping): boolean => { 688 if (canManageAllMappings(user)) { 689 return true; 690 } 691 if (!canManageOwnMappings(user)) { 692 return false; 693 } 694 return mapping.createdByUserId === user.id; 695}; 696 697const getVisibleMappings = (config: AppConfig, user: AuthenticatedUser): AccountMapping[] => { 698 if (canViewAllMappings(user)) { 699 return config.mappings; 700 } 701 702 return config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 703}; 704 705const getVisibleMappingIdSet = (config: AppConfig, user: AuthenticatedUser): Set<string> => 706 new Set(getVisibleMappings(config, user).map((mapping) => mapping.id)); 707 708const getVisibleMappingIdentitySets = (config: AppConfig, user: AuthenticatedUser) => { 709 const visible = getVisibleMappings(config, user); 710 const twitterUsernames = new Set<string>(); 711 const bskyIdentifiers = new Set<string>(); 712 713 for (const mapping of visible) { 714 for (const username of mapping.twitterUsernames) { 715 twitterUsernames.add(normalizeActor(username)); 716 } 717 bskyIdentifiers.add(normalizeActor(mapping.bskyIdentifier)); 718 } 719 720 return { 721 twitterUsernames, 722 bskyIdentifiers, 723 }; 724}; 725 726const sanitizeMapping = ( 727 mapping: AccountMapping, 728 usersById: Map<string, WebUser>, 729 requester: AuthenticatedUser, 730): MappingResponse => { 731 const { bskyPassword: _password, ...rest } = mapping; 732 const createdBy = mapping.createdByUserId ? usersById.get(mapping.createdByUserId) : undefined; 733 const ownerLabel = sanitizeLabelForRequester(mapping.owner, requester); 734 735 const response: MappingResponse = { 736 ...rest, 737 owner: ownerLabel, 738 createdByLabel: createdBy 739 ? requester.isAdmin 740 ? getUserDisplayLabel(createdBy) 741 : getUserPublicLabel(createdBy) 742 : ownerLabel, 743 }; 744 745 if (requester.isAdmin && createdBy) { 746 response.createdByUser = { 747 id: createdBy.id, 748 username: createdBy.username, 749 email: createdBy.email, 750 role: createdBy.role, 751 }; 752 } 753 754 return response; 755}; 756 757const parseTwitterUsernames = (value: unknown): string[] => { 758 const seen = new Set<string>(); 759 const usernames: string[] = []; 760 const add = (candidate: unknown) => { 761 if (typeof candidate !== 'string') { 762 return; 763 } 764 const normalized = normalizeActor(candidate); 765 if (!normalized || seen.has(normalized)) { 766 return; 767 } 768 seen.add(normalized); 769 usernames.push(normalized); 770 }; 771 772 if (Array.isArray(value)) { 773 for (const candidate of value) { 774 add(candidate); 775 } 776 } else if (typeof value === 'string') { 777 for (const candidate of value.split(',')) { 778 add(candidate); 779 } 780 } 781 782 return usernames; 783}; 784 785const getAccessibleGroups = (config: AppConfig, user: AuthenticatedUser) => { 786 const allGroups = Array.isArray(config.groups) 787 ? config.groups.filter((group) => getNormalizedGroupKey(group.name) !== RESERVED_UNGROUPED_KEY) 788 : []; 789 790 if (canViewAllMappings(user)) { 791 return allGroups; 792 } 793 794 const visibleMappings = getVisibleMappings(config, user); 795 const allowedKeys = new Set<string>(); 796 for (const mapping of visibleMappings) { 797 const key = getNormalizedGroupKey(mapping.groupName); 798 if (key && key !== RESERVED_UNGROUPED_KEY) { 799 allowedKeys.add(key); 800 } 801 } 802 803 const merged = new Map<string, { name: string; emoji?: string }>(); 804 for (const group of allGroups) { 805 const key = getNormalizedGroupKey(group.name); 806 if (!allowedKeys.has(key)) { 807 continue; 808 } 809 merged.set(key, group); 810 } 811 812 for (const mapping of visibleMappings) { 813 const groupName = normalizeGroupName(mapping.groupName); 814 if (!groupName || getNormalizedGroupKey(groupName) === RESERVED_UNGROUPED_KEY) { 815 continue; 816 } 817 const key = getNormalizedGroupKey(groupName); 818 if (!merged.has(key)) { 819 merged.set(key, { 820 name: groupName, 821 ...(mapping.groupEmoji ? { emoji: mapping.groupEmoji } : {}), 822 }); 823 } 824 } 825 826 return [...merged.values()]; 827}; 828 829const parsePermissionsInput = (rawPermissions: unknown, role: UserRole): UserPermissions => { 830 if (role === 'admin') { 831 return { ...ADMIN_USER_PERMISSIONS }; 832 } 833 834 const defaults = getDefaultUserPermissions(role); 835 if (!rawPermissions || typeof rawPermissions !== 'object') { 836 return defaults; 837 } 838 839 const record = rawPermissions as Record<string, unknown>; 840 return { 841 viewAllMappings: normalizeBoolean(record.viewAllMappings, defaults.viewAllMappings), 842 manageOwnMappings: normalizeBoolean(record.manageOwnMappings, defaults.manageOwnMappings), 843 manageAllMappings: normalizeBoolean(record.manageAllMappings, defaults.manageAllMappings), 844 manageGroups: normalizeBoolean(record.manageGroups, defaults.manageGroups), 845 queueBackfills: normalizeBoolean(record.queueBackfills, defaults.queueBackfills), 846 runNow: normalizeBoolean(record.runNow, defaults.runNow), 847 }; 848}; 849 850const validatePassword = (password: unknown): string | undefined => { 851 if (typeof password !== 'string' || password.length < PASSWORD_MIN_LENGTH) { 852 return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`; 853 } 854 return undefined; 855}; 856 857const buildUserSummary = (config: AppConfig, requester: AuthenticatedUser): UserSummaryResponse[] => { 858 const usersById = createUserLookupById(config); 859 return config.users 860 .map((user) => { 861 const ownedMappings = config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 862 const activeMappings = ownedMappings.filter((mapping) => mapping.enabled); 863 return { 864 id: user.id, 865 username: user.username, 866 email: user.email, 867 role: user.role, 868 isAdmin: user.role === 'admin', 869 permissions: user.permissions, 870 createdAt: user.createdAt, 871 updatedAt: user.updatedAt, 872 mappingCount: ownedMappings.length, 873 activeMappingCount: activeMappings.length, 874 mappings: ownedMappings.map((mapping) => sanitizeMapping(mapping, usersById, requester)), 875 }; 876 }) 877 .sort((a, b) => { 878 if (a.isAdmin && !b.isAdmin) { 879 return -1; 880 } 881 if (!a.isAdmin && b.isAdmin) { 882 return 1; 883 } 884 885 const aLabel = (a.username || a.email || '').toLowerCase(); 886 const bLabel = (b.username || b.email || '').toLowerCase(); 887 return aLabel.localeCompare(bLabel); 888 }); 889}; 890 891const ensureUniqueIdentity = ( 892 config: AppConfig, 893 userId: string | undefined, 894 username?: string, 895 email?: string, 896): string | null => { 897 if (username) { 898 const usernameTaken = config.users.some( 899 (user) => user.id !== userId && normalizeUsername(user.username) === username, 900 ); 901 if (usernameTaken) { 902 return 'Username already exists.'; 903 } 904 } 905 if (email) { 906 const emailTaken = config.users.some((user) => user.id !== userId && normalizeEmail(user.email) === email); 907 if (emailTaken) { 908 return 'Email already exists.'; 909 } 910 } 911 return null; 912}; 913 914const authenticateToken = (req: any, res: any, next: any) => { 915 const authHeader = req.headers.authorization; 916 const token = authHeader?.split(' ')[1]; 917 918 if (!token) { 919 res.sendStatus(401); 920 return; 921 } 922 923 try { 924 const decoded = jwt.verify(token, JWT_SECRET); 925 if (!decoded || typeof decoded !== 'object') { 926 res.sendStatus(403); 927 return; 928 } 929 930 const config = getConfig(); 931 const user = findUserFromTokenPayload(config, decoded as Record<string, unknown>); 932 if (!user) { 933 res.sendStatus(401); 934 return; 935 } 936 937 req.user = toAuthenticatedUser(user); 938 next(); 939 } catch { 940 res.sendStatus(403); 941 } 942}; 943 944const requireAdmin = (req: any, res: any, next: any) => { 945 if (!req.user?.isAdmin) { 946 res.status(403).json({ error: 'Admin access required' }); 947 return; 948 } 949 next(); 950}; 951 952function reconcileUpdateJobState() { 953 if (!updateJobState.running) { 954 return; 955 } 956 957 if (isProcessAlive(updateJobState.pid)) { 958 return; 959 } 960 961 updateJobState = { 962 ...updateJobState, 963 running: false, 964 finishedAt: updateJobState.finishedAt || Date.now(), 965 exitCode: updateJobState.exitCode ?? null, 966 signal: updateJobState.signal ?? null, 967 }; 968} 969 970function getUpdateStatusPayload(): UpdateStatusPayload { 971 reconcileUpdateJobState(); 972 return { 973 ...updateJobState, 974 logTail: readLogTail(updateJobState.logFile), 975 }; 976} 977 978function startUpdateJob(startedBy: string): { ok: true; state: UpdateStatusPayload } | { ok: false; message: string } { 979 reconcileUpdateJobState(); 980 981 if (updateJobState.running) { 982 return { ok: false, message: 'Update already running.' }; 983 } 984 985 if (!fs.existsSync(UPDATE_SCRIPT_PATH)) { 986 return { ok: false, message: 'update.sh not found in app root.' }; 987 } 988 989 fs.mkdirSync(UPDATE_LOG_DIR, { recursive: true }); 990 const logFile = path.join(UPDATE_LOG_DIR, `update-${Date.now()}.log`); 991 const logFd = fs.openSync(logFile, 'a'); 992 fs.writeSync(logFd, `[${new Date().toISOString()}] Update requested by ${startedBy}\n`); 993 994 try { 995 const child = spawn('bash', [UPDATE_SCRIPT_PATH], { 996 cwd: APP_ROOT_DIR, 997 detached: true, 998 stdio: ['ignore', logFd, logFd], 999 env: process.env, 1000 }); 1001 1002 updateJobState = { 1003 running: true, 1004 pid: child.pid, 1005 startedAt: Date.now(), 1006 startedBy, 1007 logFile, 1008 finishedAt: undefined, 1009 exitCode: undefined, 1010 signal: undefined, 1011 }; 1012 1013 child.on('error', (error) => { 1014 fs.appendFileSync(logFile, `[${new Date().toISOString()}] Failed to launch updater: ${error.message}\n`); 1015 updateJobState = { 1016 ...updateJobState, 1017 running: false, 1018 finishedAt: Date.now(), 1019 exitCode: 1, 1020 }; 1021 }); 1022 1023 child.on('exit', (code, signal) => { 1024 const success = code === 0; 1025 fs.appendFileSync( 1026 logFile, 1027 `[${new Date().toISOString()}] Updater exited (${success ? 'success' : 'failure'}) code=${code ?? 'null'} signal=${signal ?? 'null'}\n`, 1028 ); 1029 updateJobState = { 1030 ...updateJobState, 1031 running: false, 1032 finishedAt: Date.now(), 1033 exitCode: code ?? null, 1034 signal: signal ?? null, 1035 }; 1036 }); 1037 1038 child.unref(); 1039 return { ok: true, state: getUpdateStatusPayload() }; 1040 } catch (error) { 1041 return { ok: false, message: `Failed to start update process: ${(error as Error).message}` }; 1042 } finally { 1043 fs.closeSync(logFd); 1044 } 1045} 1046 1047// --- Auth Routes --- 1048 1049app.get('/api/auth/bootstrap-status', (_req, res) => { 1050 const config = getConfig(); 1051 res.json({ bootstrapOpen: config.users.length === 0 }); 1052}); 1053 1054app.post('/api/register', async (req, res) => { 1055 const config = getConfig(); 1056 if (config.users.length > 0) { 1057 res.status(403).json({ error: 'Registration is disabled. Ask an admin to create your account.' }); 1058 return; 1059 } 1060 1061 const email = normalizeEmail(req.body?.email); 1062 const username = normalizeUsername(req.body?.username); 1063 const password = req.body?.password; 1064 1065 if (!email && !username) { 1066 res.status(400).json({ error: 'Username or email is required.' }); 1067 return; 1068 } 1069 1070 const passwordError = validatePassword(password); 1071 if (passwordError) { 1072 res.status(400).json({ error: passwordError }); 1073 return; 1074 } 1075 1076 const uniqueIdentityError = ensureUniqueIdentity(config, undefined, username, email); 1077 if (uniqueIdentityError) { 1078 res.status(400).json({ error: uniqueIdentityError }); 1079 return; 1080 } 1081 1082 const nowIso = new Date().toISOString(); 1083 const newUser: WebUser = { 1084 id: randomUUID(), 1085 username, 1086 email, 1087 passwordHash: await bcrypt.hash(password, 10), 1088 role: 'admin', 1089 permissions: { ...ADMIN_USER_PERMISSIONS }, 1090 createdAt: nowIso, 1091 updatedAt: nowIso, 1092 }; 1093 1094 config.users.push(newUser); 1095 1096 if (config.mappings.length > 0) { 1097 config.mappings = config.mappings.map((mapping) => ({ 1098 ...mapping, 1099 createdByUserId: mapping.createdByUserId || newUser.id, 1100 owner: mapping.owner || getUserPublicLabel(newUser), 1101 })); 1102 } 1103 1104 saveConfig(config); 1105 1106 res.json({ success: true }); 1107}); 1108 1109app.post('/api/login', async (req, res) => { 1110 const password = req.body?.password; 1111 const identifier = normalizeOptionalString(req.body?.identifier) ?? normalizeOptionalString(req.body?.email); 1112 if (!identifier || typeof password !== 'string') { 1113 res.status(400).json({ error: 'Username/email and password are required.' }); 1114 return; 1115 } 1116 1117 const config = getConfig(); 1118 const user = findUserByIdentifier(config, identifier); 1119 1120 if (!user || !(await bcrypt.compare(password, user.passwordHash))) { 1121 res.status(401).json({ error: 'Invalid credentials' }); 1122 return; 1123 } 1124 1125 const token = issueTokenForUser(user); 1126 res.json({ token, isAdmin: user.role === 'admin' }); 1127}); 1128 1129app.get('/api/me', authenticateToken, (req: any, res) => { 1130 res.json(serializeAuthenticatedUser(req.user)); 1131}); 1132 1133app.post('/api/me/change-email', authenticateToken, async (req: any, res) => { 1134 const config = getConfig(); 1135 const userIndex = config.users.findIndex((user) => user.id === req.user.id); 1136 const user = config.users[userIndex]; 1137 if (userIndex === -1 || !user) { 1138 res.status(404).json({ error: 'User not found.' }); 1139 return; 1140 } 1141 1142 const currentEmail = normalizeEmail(req.body?.currentEmail); 1143 const newEmail = normalizeEmail(req.body?.newEmail); 1144 const password = req.body?.password; 1145 if (!newEmail) { 1146 res.status(400).json({ error: 'A new email is required.' }); 1147 return; 1148 } 1149 if (typeof password !== 'string') { 1150 res.status(400).json({ error: 'Password is required.' }); 1151 return; 1152 } 1153 1154 const existingEmail = normalizeEmail(user.email); 1155 if (existingEmail && currentEmail !== existingEmail) { 1156 res.status(400).json({ error: 'Current email does not match.' }); 1157 return; 1158 } 1159 1160 if (!(await bcrypt.compare(password, user.passwordHash))) { 1161 res.status(401).json({ error: 'Password verification failed.' }); 1162 return; 1163 } 1164 1165 const uniqueIdentityError = ensureUniqueIdentity(config, user.id, normalizeUsername(user.username), newEmail); 1166 if (uniqueIdentityError) { 1167 res.status(400).json({ error: uniqueIdentityError }); 1168 return; 1169 } 1170 1171 const updatedUser: WebUser = { 1172 ...user, 1173 email: newEmail, 1174 updatedAt: new Date().toISOString(), 1175 }; 1176 config.users[userIndex] = updatedUser; 1177 saveConfig(config); 1178 1179 const token = issueTokenForUser(updatedUser); 1180 res.json({ 1181 success: true, 1182 token, 1183 me: serializeAuthenticatedUser(toAuthenticatedUser(updatedUser)), 1184 }); 1185}); 1186 1187app.post('/api/me/change-password', authenticateToken, async (req: any, res) => { 1188 const config = getConfig(); 1189 const userIndex = config.users.findIndex((user) => user.id === req.user.id); 1190 const user = config.users[userIndex]; 1191 if (userIndex === -1 || !user) { 1192 res.status(404).json({ error: 'User not found.' }); 1193 return; 1194 } 1195 1196 const currentPassword = req.body?.currentPassword; 1197 const newPassword = req.body?.newPassword; 1198 if (typeof currentPassword !== 'string') { 1199 res.status(400).json({ error: 'Current password is required.' }); 1200 return; 1201 } 1202 1203 const passwordError = validatePassword(newPassword); 1204 if (passwordError) { 1205 res.status(400).json({ error: passwordError }); 1206 return; 1207 } 1208 1209 if (!(await bcrypt.compare(currentPassword, user.passwordHash))) { 1210 res.status(401).json({ error: 'Current password is incorrect.' }); 1211 return; 1212 } 1213 1214 config.users[userIndex] = { 1215 ...user, 1216 passwordHash: await bcrypt.hash(newPassword, 10), 1217 updatedAt: new Date().toISOString(), 1218 }; 1219 saveConfig(config); 1220 res.json({ success: true }); 1221}); 1222 1223app.get('/api/admin/users', authenticateToken, requireAdmin, (req: any, res) => { 1224 const config = getConfig(); 1225 res.json(buildUserSummary(config, req.user)); 1226}); 1227 1228app.post('/api/admin/users', authenticateToken, requireAdmin, async (req: any, res) => { 1229 const config = getConfig(); 1230 const username = normalizeUsername(req.body?.username); 1231 const email = normalizeEmail(req.body?.email); 1232 const password = req.body?.password; 1233 const role: UserRole = req.body?.isAdmin ? 'admin' : 'user'; 1234 const permissions = parsePermissionsInput(req.body?.permissions, role); 1235 1236 if (!username && !email) { 1237 res.status(400).json({ error: 'Username or email is required.' }); 1238 return; 1239 } 1240 1241 const passwordError = validatePassword(password); 1242 if (passwordError) { 1243 res.status(400).json({ error: passwordError }); 1244 return; 1245 } 1246 1247 const uniqueIdentityError = ensureUniqueIdentity(config, undefined, username, email); 1248 if (uniqueIdentityError) { 1249 res.status(400).json({ error: uniqueIdentityError }); 1250 return; 1251 } 1252 1253 const nowIso = new Date().toISOString(); 1254 const newUser: WebUser = { 1255 id: randomUUID(), 1256 username, 1257 email, 1258 passwordHash: await bcrypt.hash(password, 10), 1259 role, 1260 permissions, 1261 createdAt: nowIso, 1262 updatedAt: nowIso, 1263 }; 1264 1265 config.users.push(newUser); 1266 saveConfig(config); 1267 1268 const summary = buildUserSummary(config, req.user).find((user) => user.id === newUser.id); 1269 res.json(summary || null); 1270}); 1271 1272app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req: any, res) => { 1273 const { id } = req.params; 1274 const config = getConfig(); 1275 const userIndex = config.users.findIndex((user) => user.id === id); 1276 const user = config.users[userIndex]; 1277 if (userIndex === -1 || !user) { 1278 res.status(404).json({ error: 'User not found.' }); 1279 return; 1280 } 1281 1282 const requestedRole: UserRole = 1283 req.body?.isAdmin === true ? 'admin' : req.body?.isAdmin === false ? 'user' : user.role; 1284 1285 if (user.id === req.user.id && requestedRole !== 'admin') { 1286 res.status(400).json({ error: 'You cannot remove your own admin access.' }); 1287 return; 1288 } 1289 1290 if (user.role === 'admin' && requestedRole !== 'admin') { 1291 const adminCount = config.users.filter((entry) => entry.role === 'admin').length; 1292 if (adminCount <= 1) { 1293 res.status(400).json({ error: 'At least one admin must remain.' }); 1294 return; 1295 } 1296 } 1297 1298 const username = 1299 req.body?.username !== undefined ? normalizeUsername(req.body?.username) : normalizeUsername(user.username); 1300 const email = req.body?.email !== undefined ? normalizeEmail(req.body?.email) : normalizeEmail(user.email); 1301 1302 if (!username && !email) { 1303 res.status(400).json({ error: 'User must keep at least a username or email.' }); 1304 return; 1305 } 1306 1307 const uniqueIdentityError = ensureUniqueIdentity(config, user.id, username, email); 1308 if (uniqueIdentityError) { 1309 res.status(400).json({ error: uniqueIdentityError }); 1310 return; 1311 } 1312 1313 const permissions = 1314 req.body?.permissions !== undefined || req.body?.isAdmin !== undefined 1315 ? parsePermissionsInput(req.body?.permissions, requestedRole) 1316 : requestedRole === 'admin' 1317 ? { ...ADMIN_USER_PERMISSIONS } 1318 : user.permissions; 1319 1320 config.users[userIndex] = { 1321 ...user, 1322 username, 1323 email, 1324 role: requestedRole, 1325 permissions, 1326 updatedAt: new Date().toISOString(), 1327 }; 1328 1329 saveConfig(config); 1330 const summary = buildUserSummary(config, req.user).find((entry) => entry.id === id); 1331 res.json(summary || null); 1332}); 1333 1334app.post('/api/admin/users/:id/reset-password', authenticateToken, requireAdmin, async (req, res) => { 1335 const { id } = req.params; 1336 const config = getConfig(); 1337 const userIndex = config.users.findIndex((user) => user.id === id); 1338 const user = config.users[userIndex]; 1339 if (userIndex === -1 || !user) { 1340 res.status(404).json({ error: 'User not found.' }); 1341 return; 1342 } 1343 1344 const newPassword = req.body?.newPassword; 1345 const passwordError = validatePassword(newPassword); 1346 if (passwordError) { 1347 res.status(400).json({ error: passwordError }); 1348 return; 1349 } 1350 1351 config.users[userIndex] = { 1352 ...user, 1353 passwordHash: await bcrypt.hash(newPassword, 10), 1354 updatedAt: new Date().toISOString(), 1355 }; 1356 saveConfig(config); 1357 res.json({ success: true }); 1358}); 1359 1360app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req: any, res) => { 1361 const { id } = req.params; 1362 const config = getConfig(); 1363 const userIndex = config.users.findIndex((user) => user.id === id); 1364 const user = config.users[userIndex]; 1365 1366 if (userIndex === -1 || !user) { 1367 res.status(404).json({ error: 'User not found.' }); 1368 return; 1369 } 1370 1371 if (user.id === req.user.id) { 1372 res.status(400).json({ error: 'You cannot delete your own account.' }); 1373 return; 1374 } 1375 1376 if (user.role === 'admin') { 1377 const adminCount = config.users.filter((entry) => entry.role === 'admin').length; 1378 if (adminCount <= 1) { 1379 res.status(400).json({ error: 'At least one admin must remain.' }); 1380 return; 1381 } 1382 } 1383 1384 const ownedMappings = config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 1385 const ownedMappingIds = new Set(ownedMappings.map((mapping) => mapping.id)); 1386 config.mappings = config.mappings.map((mapping) => 1387 mapping.createdByUserId === user.id 1388 ? { 1389 ...mapping, 1390 enabled: false, 1391 } 1392 : mapping, 1393 ); 1394 1395 config.users.splice(userIndex, 1); 1396 pendingBackfills = pendingBackfills.filter((backfill) => !ownedMappingIds.has(backfill.id)); 1397 saveConfig(config); 1398 1399 res.json({ 1400 success: true, 1401 disabledMappings: ownedMappings.length, 1402 }); 1403}); 1404 1405// --- Mapping Routes --- 1406 1407app.get('/api/mappings', authenticateToken, (req: any, res) => { 1408 const config = getConfig(); 1409 const usersById = createUserLookupById(config); 1410 const visibleMappings = getVisibleMappings(config, req.user); 1411 res.json(visibleMappings.map((mapping) => sanitizeMapping(mapping, usersById, req.user))); 1412}); 1413 1414app.get('/api/groups', authenticateToken, (req: any, res) => { 1415 const config = getConfig(); 1416 res.json(getAccessibleGroups(config, req.user)); 1417}); 1418 1419app.post('/api/groups', authenticateToken, (req: any, res) => { 1420 if (!canManageGroups(req.user)) { 1421 res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1422 return; 1423 } 1424 1425 const config = getConfig(); 1426 const normalizedName = normalizeGroupName(req.body?.name); 1427 const normalizedEmoji = normalizeGroupEmoji(req.body?.emoji); 1428 1429 if (!normalizedName) { 1430 res.status(400).json({ error: 'Group name is required.' }); 1431 return; 1432 } 1433 1434 if (getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) { 1435 res.status(400).json({ error: '"Ungrouped" is reserved for default behavior.' }); 1436 return; 1437 } 1438 1439 ensureGroupExists(config, normalizedName, normalizedEmoji); 1440 saveConfig(config); 1441 1442 const group = config.groups.find( 1443 (entry) => getNormalizedGroupKey(entry.name) === getNormalizedGroupKey(normalizedName), 1444 ); 1445 res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) }); 1446}); 1447 1448app.put('/api/groups/:groupKey', authenticateToken, (req: any, res) => { 1449 if (!canManageGroups(req.user)) { 1450 res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1451 return; 1452 } 1453 1454 const currentGroupKey = getNormalizedGroupKey(req.params.groupKey); 1455 if (!currentGroupKey || currentGroupKey === RESERVED_UNGROUPED_KEY) { 1456 res.status(400).json({ error: 'Invalid group key.' }); 1457 return; 1458 } 1459 1460 const requestedName = normalizeGroupName(req.body?.name); 1461 const requestedEmoji = normalizeGroupEmoji(req.body?.emoji); 1462 if (!requestedName) { 1463 res.status(400).json({ error: 'Group name is required.' }); 1464 return; 1465 } 1466 1467 const requestedGroupKey = getNormalizedGroupKey(requestedName); 1468 if (requestedGroupKey === RESERVED_UNGROUPED_KEY) { 1469 res.status(400).json({ error: '"Ungrouped" is reserved and cannot be edited.' }); 1470 return; 1471 } 1472 1473 const config = getConfig(); 1474 if (!Array.isArray(config.groups)) { 1475 config.groups = []; 1476 } 1477 1478 const groupIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === currentGroupKey); 1479 if (groupIndex === -1) { 1480 res.status(404).json({ error: 'Group not found.' }); 1481 return; 1482 } 1483 1484 const mergeIndex = config.groups.findIndex( 1485 (group, index) => index !== groupIndex && getNormalizedGroupKey(group.name) === requestedGroupKey, 1486 ); 1487 1488 let finalName = requestedName; 1489 let finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[groupIndex]?.emoji); 1490 if (mergeIndex !== -1) { 1491 finalName = normalizeGroupName(config.groups[mergeIndex]?.name) || requestedName; 1492 finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[mergeIndex]?.emoji) || finalEmoji; 1493 1494 config.groups[mergeIndex] = { 1495 name: finalName, 1496 ...(finalEmoji ? { emoji: finalEmoji } : {}), 1497 }; 1498 config.groups.splice(groupIndex, 1); 1499 } else { 1500 config.groups[groupIndex] = { 1501 name: finalName, 1502 ...(finalEmoji ? { emoji: finalEmoji } : {}), 1503 }; 1504 } 1505 1506 const keysToRewrite = new Set([currentGroupKey, requestedGroupKey]); 1507 config.mappings = config.mappings.map((mapping) => { 1508 const mappingGroupKey = getNormalizedGroupKey(mapping.groupName); 1509 if (!keysToRewrite.has(mappingGroupKey)) { 1510 return mapping; 1511 } 1512 return { 1513 ...mapping, 1514 groupName: finalName, 1515 groupEmoji: finalEmoji || undefined, 1516 }; 1517 }); 1518 1519 saveConfig(config); 1520 res.json({ 1521 name: finalName, 1522 ...(finalEmoji ? { emoji: finalEmoji } : {}), 1523 }); 1524}); 1525 1526app.delete('/api/groups/:groupKey', authenticateToken, (req: any, res) => { 1527 if (!canManageGroups(req.user)) { 1528 res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1529 return; 1530 } 1531 1532 const groupKey = getNormalizedGroupKey(req.params.groupKey); 1533 if (!groupKey || groupKey === RESERVED_UNGROUPED_KEY) { 1534 res.status(400).json({ error: 'Invalid group key.' }); 1535 return; 1536 } 1537 1538 const config = getConfig(); 1539 if (!Array.isArray(config.groups)) { 1540 config.groups = []; 1541 } 1542 1543 const beforeCount = config.groups.length; 1544 config.groups = config.groups.filter((group) => getNormalizedGroupKey(group.name) !== groupKey); 1545 if (config.groups.length === beforeCount) { 1546 res.status(404).json({ error: 'Group not found.' }); 1547 return; 1548 } 1549 1550 let reassigned = 0; 1551 config.mappings = config.mappings.map((mapping) => { 1552 if (getNormalizedGroupKey(mapping.groupName) !== groupKey) { 1553 return mapping; 1554 } 1555 reassigned += 1; 1556 return { 1557 ...mapping, 1558 groupName: undefined, 1559 groupEmoji: undefined, 1560 }; 1561 }); 1562 1563 saveConfig(config); 1564 res.json({ success: true, reassignedCount: reassigned }); 1565}); 1566 1567app.post('/api/mappings', authenticateToken, (req: any, res) => { 1568 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 1569 res.status(403).json({ error: 'You do not have permission to create mappings.' }); 1570 return; 1571 } 1572 1573 const config = getConfig(); 1574 const usersById = createUserLookupById(config); 1575 const twitterUsernames = parseTwitterUsernames(req.body?.twitterUsernames); 1576 if (twitterUsernames.length === 0) { 1577 res.status(400).json({ error: 'At least one Twitter username is required.' }); 1578 return; 1579 } 1580 1581 const bskyIdentifier = normalizeActor(req.body?.bskyIdentifier || ''); 1582 const bskyPassword = normalizeOptionalString(req.body?.bskyPassword); 1583 if (!bskyIdentifier || !bskyPassword) { 1584 res.status(400).json({ error: 'Bluesky identifier and app password are required.' }); 1585 return; 1586 } 1587 1588 let createdByUserId = req.user.id; 1589 const requestedCreatorId = normalizeOptionalString(req.body?.createdByUserId); 1590 if (requestedCreatorId && requestedCreatorId !== req.user.id) { 1591 if (!canManageAllMappings(req.user)) { 1592 res.status(403).json({ error: 'You cannot assign mappings to another user.' }); 1593 return; 1594 } 1595 if (!usersById.has(requestedCreatorId)) { 1596 res.status(400).json({ error: 'Selected account owner does not exist.' }); 1597 return; 1598 } 1599 createdByUserId = requestedCreatorId; 1600 } 1601 1602 const ownerUser = usersById.get(createdByUserId); 1603 const owner = 1604 normalizeOptionalString(req.body?.owner) || (ownerUser ? getUserPublicLabel(ownerUser) : getActorPublicLabel(req.user)); 1605 const normalizedGroupName = normalizeGroupName(req.body?.groupName); 1606 const normalizedGroupEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 1607 1608 const newMapping: AccountMapping = { 1609 id: randomUUID(), 1610 twitterUsernames, 1611 bskyIdentifier, 1612 bskyPassword, 1613 bskyServiceUrl: normalizeOptionalString(req.body?.bskyServiceUrl) || 'https://bsky.social', 1614 enabled: true, 1615 owner, 1616 groupName: normalizedGroupName || undefined, 1617 groupEmoji: normalizedGroupEmoji || undefined, 1618 createdByUserId, 1619 }; 1620 1621 ensureGroupExists(config, normalizedGroupName, normalizedGroupEmoji); 1622 config.mappings.push(newMapping); 1623 saveConfig(config); 1624 res.json(sanitizeMapping(newMapping, createUserLookupById(config), req.user)); 1625}); 1626 1627app.put('/api/mappings/:id', authenticateToken, (req: any, res) => { 1628 const { id } = req.params; 1629 const config = getConfig(); 1630 const usersById = createUserLookupById(config); 1631 const index = config.mappings.findIndex((mapping) => mapping.id === id); 1632 const existingMapping = config.mappings[index]; 1633 1634 if (index === -1 || !existingMapping) { 1635 res.status(404).json({ error: 'Mapping not found' }); 1636 return; 1637 } 1638 1639 if (!canManageMapping(req.user, existingMapping)) { 1640 res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 1641 return; 1642 } 1643 1644 let twitterUsernames: string[] = existingMapping.twitterUsernames; 1645 if (req.body?.twitterUsernames !== undefined) { 1646 twitterUsernames = parseTwitterUsernames(req.body.twitterUsernames); 1647 if (twitterUsernames.length === 0) { 1648 res.status(400).json({ error: 'At least one Twitter username is required.' }); 1649 return; 1650 } 1651 } 1652 1653 let bskyIdentifier = existingMapping.bskyIdentifier; 1654 if (req.body?.bskyIdentifier !== undefined) { 1655 const normalizedIdentifier = normalizeActor(req.body?.bskyIdentifier); 1656 if (!normalizedIdentifier) { 1657 res.status(400).json({ error: 'Invalid Bluesky identifier.' }); 1658 return; 1659 } 1660 bskyIdentifier = normalizedIdentifier; 1661 } 1662 1663 let createdByUserId = existingMapping.createdByUserId || req.user.id; 1664 if (req.body?.createdByUserId !== undefined) { 1665 if (!canManageAllMappings(req.user)) { 1666 res.status(403).json({ error: 'You cannot reassign mapping ownership.' }); 1667 return; 1668 } 1669 1670 const requestedCreatorId = normalizeOptionalString(req.body?.createdByUserId); 1671 if (!requestedCreatorId || !usersById.has(requestedCreatorId)) { 1672 res.status(400).json({ error: 'Selected account owner does not exist.' }); 1673 return; 1674 } 1675 createdByUserId = requestedCreatorId; 1676 } 1677 1678 let nextGroupName = existingMapping.groupName; 1679 if (req.body?.groupName !== undefined) { 1680 const normalizedName = normalizeGroupName(req.body?.groupName); 1681 nextGroupName = normalizedName || undefined; 1682 } 1683 1684 let nextGroupEmoji = existingMapping.groupEmoji; 1685 if (req.body?.groupEmoji !== undefined) { 1686 const normalizedEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 1687 nextGroupEmoji = normalizedEmoji || undefined; 1688 } 1689 1690 const ownerUser = usersById.get(createdByUserId); 1691 const owner = 1692 req.body?.owner !== undefined 1693 ? normalizeOptionalString(req.body?.owner) || existingMapping.owner 1694 : existingMapping.owner || (ownerUser ? getUserPublicLabel(ownerUser) : undefined); 1695 1696 const updatedMapping: AccountMapping = { 1697 ...existingMapping, 1698 twitterUsernames, 1699 bskyIdentifier, 1700 bskyPassword: normalizeOptionalString(req.body?.bskyPassword) || existingMapping.bskyPassword, 1701 bskyServiceUrl: normalizeOptionalString(req.body?.bskyServiceUrl) || existingMapping.bskyServiceUrl, 1702 owner, 1703 groupName: nextGroupName, 1704 groupEmoji: nextGroupEmoji, 1705 createdByUserId, 1706 }; 1707 1708 ensureGroupExists(config, nextGroupName, nextGroupEmoji); 1709 config.mappings[index] = updatedMapping; 1710 saveConfig(config); 1711 res.json(sanitizeMapping(updatedMapping, createUserLookupById(config), req.user)); 1712}); 1713 1714app.delete('/api/mappings/:id', authenticateToken, (req: any, res) => { 1715 const { id } = req.params; 1716 const config = getConfig(); 1717 const mapping = config.mappings.find((entry) => entry.id === id); 1718 1719 if (!mapping) { 1720 res.status(404).json({ error: 'Mapping not found' }); 1721 return; 1722 } 1723 1724 if (!canManageMapping(req.user, mapping)) { 1725 res.status(403).json({ error: 'You do not have permission to delete this mapping.' }); 1726 return; 1727 } 1728 1729 config.mappings = config.mappings.filter((entry) => entry.id !== id); 1730 pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 1731 saveConfig(config); 1732 res.json({ success: true }); 1733}); 1734 1735app.delete('/api/mappings/:id/cache', authenticateToken, requireAdmin, (req, res) => { 1736 const { id } = req.params; 1737 const config = getConfig(); 1738 const mapping = config.mappings.find((m) => m.id === id); 1739 if (!mapping) { 1740 res.status(404).json({ error: 'Mapping not found' }); 1741 return; 1742 } 1743 1744 for (const username of mapping.twitterUsernames) { 1745 dbService.deleteTweetsByUsername(username); 1746 } 1747 1748 res.json({ success: true, message: 'Cache cleared for all associated accounts' }); 1749}); 1750 1751app.post('/api/mappings/:id/delete-all-posts', authenticateToken, requireAdmin, async (req, res) => { 1752 const { id } = req.params; 1753 const config = getConfig(); 1754 const mapping = config.mappings.find((m) => m.id === id); 1755 if (!mapping) { 1756 res.status(404).json({ error: 'Mapping not found' }); 1757 return; 1758 } 1759 1760 try { 1761 const deletedCount = await deleteAllPosts(id); 1762 1763 dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); 1764 1765 res.json({ 1766 success: true, 1767 message: `Deleted ${deletedCount} posts from ${mapping.bskyIdentifier} and cleared local cache.`, 1768 }); 1769 } catch (err) { 1770 console.error('Failed to delete all posts:', err); 1771 res.status(500).json({ error: (err as Error).message }); 1772 } 1773}); 1774 1775// --- Twitter Config Routes (Admin Only) --- 1776 1777app.get('/api/twitter-config', authenticateToken, requireAdmin, (_req, res) => { 1778 const config = getConfig(); 1779 res.json(config.twitter); 1780}); 1781 1782app.post('/api/twitter-config', authenticateToken, requireAdmin, (req, res) => { 1783 const { authToken, ct0, backupAuthToken, backupCt0 } = req.body; 1784 const config = getConfig(); 1785 config.twitter = { authToken, ct0, backupAuthToken, backupCt0 }; 1786 saveConfig(config); 1787 res.json({ success: true }); 1788}); 1789 1790app.get('/api/ai-config', authenticateToken, requireAdmin, (_req, res) => { 1791 const config = getConfig(); 1792 const aiConfig = config.ai || { 1793 provider: 'gemini', 1794 apiKey: config.geminiApiKey || '', 1795 }; 1796 res.json(aiConfig); 1797}); 1798 1799app.post('/api/ai-config', authenticateToken, requireAdmin, (req, res) => { 1800 const { provider, apiKey, model, baseUrl } = req.body; 1801 const config = getConfig(); 1802 1803 config.ai = { 1804 provider, 1805 apiKey, 1806 model: model || undefined, 1807 baseUrl: baseUrl || undefined, 1808 }; 1809 1810 delete config.geminiApiKey; 1811 1812 saveConfig(config); 1813 res.json({ success: true }); 1814}); 1815 1816// --- Status & Actions Routes --- 1817 1818app.get('/api/status', authenticateToken, (req: any, res) => { 1819 const config = getConfig(); 1820 const now = Date.now(); 1821 const nextRunMs = Math.max(0, nextCheckTime - now); 1822 const visibleMappingIds = getVisibleMappingIdSet(config, req.user); 1823 const scopedPendingBackfills = pendingBackfills 1824 .filter((backfill) => visibleMappingIds.has(backfill.id)) 1825 .sort((a, b) => a.sequence - b.sequence); 1826 1827 const scopedStatus = 1828 currentAppStatus.state === 'backfilling' && 1829 currentAppStatus.backfillMappingId && 1830 !visibleMappingIds.has(currentAppStatus.backfillMappingId) 1831 ? { 1832 state: 'idle', 1833 message: 'Idle', 1834 lastUpdate: currentAppStatus.lastUpdate, 1835 } 1836 : currentAppStatus; 1837 1838 res.json({ 1839 lastCheckTime, 1840 nextCheckTime, 1841 nextCheckMinutes: Math.ceil(nextRunMs / 60000), 1842 checkIntervalMinutes: config.checkIntervalMinutes, 1843 pendingBackfills: scopedPendingBackfills.map((backfill, index) => ({ 1844 ...backfill, 1845 position: index + 1, 1846 })), 1847 currentStatus: scopedStatus, 1848 }); 1849}); 1850 1851app.get('/api/version', authenticateToken, (_req, res) => { 1852 res.json(getRuntimeVersionInfo()); 1853}); 1854 1855app.get('/api/update-status', authenticateToken, requireAdmin, (_req, res) => { 1856 res.json(getUpdateStatusPayload()); 1857}); 1858 1859app.post('/api/update', authenticateToken, requireAdmin, (req: any, res) => { 1860 const startedBy = getActorLabel(req.user); 1861 const result = startUpdateJob(startedBy); 1862 if (!result.ok) { 1863 const message = result.message; 1864 const statusCode = message === 'Update already running.' ? 409 : 500; 1865 res.status(statusCode).json({ error: message }); 1866 return; 1867 } 1868 1869 res.json({ 1870 success: true, 1871 message: 'Update started. Service may restart automatically.', 1872 status: result.state, 1873 version: getRuntimeVersionInfo(), 1874 }); 1875}); 1876 1877app.post('/api/run-now', authenticateToken, (req: any, res) => { 1878 if (!canRunNow(req.user)) { 1879 res.status(403).json({ error: 'You do not have permission to run checks manually.' }); 1880 return; 1881 } 1882 1883 lastCheckTime = 0; 1884 nextCheckTime = Date.now() + 1000; 1885 res.json({ success: true, message: 'Check triggered' }); 1886}); 1887 1888app.post('/api/backfill/clear-all', authenticateToken, requireAdmin, (_req, res) => { 1889 pendingBackfills = []; 1890 updateAppStatus({ 1891 state: 'idle', 1892 message: 'All backfills cleared', 1893 backfillMappingId: undefined, 1894 backfillRequestId: undefined, 1895 }); 1896 res.json({ success: true, message: 'All backfills cleared' }); 1897}); 1898 1899app.post('/api/backfill/:id', authenticateToken, (req: any, res) => { 1900 if (!canQueueBackfills(req.user)) { 1901 res.status(403).json({ error: 'You do not have permission to queue backfills.' }); 1902 return; 1903 } 1904 1905 const { id } = req.params; 1906 const { limit } = req.body; 1907 const config = getConfig(); 1908 const mapping = config.mappings.find((m) => m.id === id); 1909 1910 if (!mapping) { 1911 res.status(404).json({ error: 'Mapping not found' }); 1912 return; 1913 } 1914 1915 if (!canManageMapping(req.user, mapping)) { 1916 res.status(403).json({ error: 'You do not have access to this mapping.' }); 1917 return; 1918 } 1919 1920 const parsedLimit = Number(limit); 1921 const safeLimit = Number.isFinite(parsedLimit) ? Math.max(1, Math.min(parsedLimit, 200)) : undefined; 1922 const queuedAt = Date.now(); 1923 const sequence = backfillSequence++; 1924 const requestId = randomUUID(); 1925 pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 1926 pendingBackfills.push({ 1927 id, 1928 limit: safeLimit, 1929 queuedAt, 1930 sequence, 1931 requestId, 1932 }); 1933 pendingBackfills.sort((a, b) => a.sequence - b.sequence); 1934 1935 res.json({ 1936 success: true, 1937 message: `Backfill queued for @${mapping.twitterUsernames.join(', ')}`, 1938 requestId, 1939 }); 1940}); 1941 1942app.delete('/api/backfill/:id', authenticateToken, (req: any, res) => { 1943 const { id } = req.params; 1944 const config = getConfig(); 1945 const mapping = config.mappings.find((entry) => entry.id === id); 1946 1947 if (!mapping) { 1948 res.status(404).json({ error: 'Mapping not found' }); 1949 return; 1950 } 1951 1952 if (!canManageMapping(req.user, mapping)) { 1953 res.status(403).json({ error: 'You do not have permission to update this queue entry.' }); 1954 return; 1955 } 1956 1957 pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 1958 res.json({ success: true }); 1959}); 1960 1961// --- Config Management Routes --- 1962 1963app.get('/api/config/export', authenticateToken, requireAdmin, (_req, res) => { 1964 const config = getConfig(); 1965 const { users, ...cleanConfig } = config; 1966 1967 res.setHeader('Content-Type', 'application/json'); 1968 res.setHeader('Content-Disposition', 'attachment; filename=tweets-2-bsky-config.json'); 1969 res.json(cleanConfig); 1970}); 1971 1972app.post('/api/config/import', authenticateToken, requireAdmin, (req, res) => { 1973 try { 1974 const importData = req.body; 1975 const currentConfig = getConfig(); 1976 1977 if (!importData.mappings || !Array.isArray(importData.mappings)) { 1978 res.status(400).json({ error: 'Invalid config format: missing mappings array' }); 1979 return; 1980 } 1981 1982 const newConfig = { 1983 ...currentConfig, 1984 mappings: importData.mappings, 1985 groups: Array.isArray(importData.groups) ? importData.groups : currentConfig.groups, 1986 twitter: importData.twitter || currentConfig.twitter, 1987 ai: importData.ai || currentConfig.ai, 1988 checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes, 1989 }; 1990 1991 saveConfig(newConfig); 1992 res.json({ success: true, message: 'Configuration imported successfully' }); 1993 } catch (err) { 1994 console.error('Import failed:', err); 1995 res.status(500).json({ error: 'Failed to process import file' }); 1996 } 1997}); 1998 1999app.get('/api/recent-activity', authenticateToken, (req: any, res) => { 2000 const limitCandidate = req.query.limit ? Number(req.query.limit) : 50; 2001 const limit = Number.isFinite(limitCandidate) ? Math.max(1, Math.min(limitCandidate, 200)) : 50; 2002 const config = getConfig(); 2003 const visibleSets = getVisibleMappingIdentitySets(config, req.user); 2004 const scanLimit = canViewAllMappings(req.user) ? limit : Math.max(limit * 6, 150); 2005 2006 const tweets = dbService.getRecentProcessedTweets(scanLimit); 2007 const filtered = canViewAllMappings(req.user) 2008 ? tweets 2009 : tweets.filter( 2010 (tweet) => 2011 visibleSets.twitterUsernames.has(normalizeActor(tweet.twitter_username)) || 2012 visibleSets.bskyIdentifiers.has(normalizeActor(tweet.bsky_identifier)), 2013 ); 2014 2015 res.json(filtered.slice(0, limit)); 2016}); 2017 2018app.post('/api/bsky/profiles', authenticateToken, async (req, res) => { 2019 const actors = Array.isArray(req.body?.actors) 2020 ? req.body.actors.filter((actor: unknown) => typeof actor === 'string') 2021 : []; 2022 2023 if (actors.length === 0) { 2024 res.json({}); 2025 return; 2026 } 2027 2028 const limitedActors = actors.slice(0, 200); 2029 const profiles = await fetchProfilesByActor(limitedActors); 2030 res.json(profiles); 2031}); 2032 2033app.get('/api/posts/search', authenticateToken, (req: any, res) => { 2034 const query = typeof req.query.q === 'string' ? req.query.q : ''; 2035 if (!query.trim()) { 2036 res.json([]); 2037 return; 2038 } 2039 2040 const requestedLimit = req.query.limit ? Number(req.query.limit) : 80; 2041 const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 200)) : 80; 2042 const searchLimit = Math.min(200, Math.max(80, limit * 4)); 2043 const config = getConfig(); 2044 const visibleSets = getVisibleMappingIdentitySets(config, req.user); 2045 2046 const scopedRows = dbService 2047 .searchMigratedTweets(query, searchLimit) 2048 .filter( 2049 (row) => 2050 canViewAllMappings(req.user) || 2051 visibleSets.twitterUsernames.has(normalizeActor(row.twitter_username)) || 2052 visibleSets.bskyIdentifiers.has(normalizeActor(row.bsky_identifier)), 2053 ) 2054 .slice(0, limit); 2055 2056 const results = scopedRows.map<LocalPostSearchResult>((row) => ({ 2057 twitterId: row.twitter_id, 2058 twitterUsername: row.twitter_username, 2059 bskyIdentifier: row.bsky_identifier, 2060 tweetText: row.tweet_text, 2061 bskyUri: row.bsky_uri, 2062 bskyCid: row.bsky_cid, 2063 createdAt: row.created_at, 2064 postUrl: buildPostUrl(row.bsky_identifier, row.bsky_uri), 2065 twitterUrl: buildTwitterPostUrl(row.twitter_username, row.twitter_id), 2066 score: Number(row.score.toFixed(2)), 2067 })); 2068 2069 res.json(results); 2070}); 2071 2072app.get('/api/posts/enriched', authenticateToken, async (req: any, res) => { 2073 const requestedLimit = req.query.limit ? Number(req.query.limit) : 24; 2074 const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 80)) : 24; 2075 const config = getConfig(); 2076 const visibleSets = getVisibleMappingIdentitySets(config, req.user); 2077 2078 const recent = dbService.getRecentProcessedTweets(limit * 8); 2079 const migratedWithUri = recent.filter( 2080 (row) => 2081 row.status === 'migrated' && 2082 row.bsky_uri && 2083 (canViewAllMappings(req.user) || 2084 visibleSets.twitterUsernames.has(normalizeActor(row.twitter_username)) || 2085 visibleSets.bskyIdentifiers.has(normalizeActor(row.bsky_identifier))), 2086 ); 2087 2088 const deduped: ProcessedTweet[] = []; 2089 const seenUris = new Set<string>(); 2090 for (const row of migratedWithUri) { 2091 const uri = row.bsky_uri; 2092 if (!uri || seenUris.has(uri)) continue; 2093 seenUris.add(uri); 2094 deduped.push(row); 2095 if (deduped.length >= limit) break; 2096 } 2097 2098 const uris = deduped.map((row) => row.bsky_uri).filter((uri): uri is string => typeof uri === 'string'); 2099 const postViewsByUri = await fetchPostViewsByUri(uris); 2100 const enriched = deduped.map((row) => buildEnrichedPost(row, row.bsky_uri ? postViewsByUri.get(row.bsky_uri) : null)); 2101 2102 res.json(enriched); 2103}); 2104// Export for use by index.ts 2105export function updateLastCheckTime() { 2106 const config = getConfig(); 2107 lastCheckTime = Date.now(); 2108 nextCheckTime = lastCheckTime + (config.checkIntervalMinutes || 5) * 60 * 1000; 2109} 2110 2111export function updateAppStatus(status: Partial<AppStatus>) { 2112 currentAppStatus = { 2113 ...currentAppStatus, 2114 ...status, 2115 lastUpdate: Date.now(), 2116 }; 2117} 2118 2119export function getPendingBackfills(): PendingBackfill[] { 2120 return [...pendingBackfills].sort((a, b) => a.sequence - b.sequence); 2121} 2122 2123export function getNextCheckTime(): number { 2124 return nextCheckTime; 2125} 2126 2127export function clearBackfill(id: string, requestId?: string) { 2128 if (requestId) { 2129 pendingBackfills = pendingBackfills.filter((bid) => !(bid.id === id && bid.requestId === requestId)); 2130 return; 2131 } 2132 pendingBackfills = pendingBackfills.filter((bid) => bid.id !== id); 2133} 2134 2135// Serve the frontend for any other route (middleware approach for Express 5) 2136app.use((_req, res) => { 2137 res.sendFile(path.join(staticAssetsDir, 'index.html')); 2138}); 2139 2140export function startServer() { 2141 app.listen(PORT, HOST as any, () => { 2142 console.log(`🚀 Web interface running at http://localhost:${PORT}`); 2143 if (HOST === '127.0.0.1' || HOST === '::1' || HOST === 'localhost') { 2144 console.log(`🔒 Bound to ${HOST} (local-only). Use Tailscale Serve or a reverse proxy for remote access.`); 2145 return; 2146 } 2147 console.log('📡 Accessible on your local network/Tailscale via your IP.'); 2148 }); 2149}