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.
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}