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