forked from
j4ck.xyz/tweets2bsky
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 fs from 'node:fs';
2import path from 'node:path';
3import { fileURLToPath } from 'node:url';
4import bcrypt from 'bcryptjs';
5import cors from 'cors';
6import express from 'express';
7import jwt from 'jsonwebtoken';
8import { getConfig, saveConfig } from './config-manager.js';
9
10const __filename = fileURLToPath(import.meta.url);
11const __dirname = path.dirname(__filename);
12
13const app = express();
14const PORT = Number(process.env.PORT) || 3000;
15const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret';
16
17// In-memory state for triggers and scheduling
18let lastCheckTime = Date.now();
19let nextCheckTime = Date.now() + (getConfig().checkIntervalMinutes || 5) * 60 * 1000;
20let pendingBackfills: string[] = [];
21
22app.use(cors());
23app.use(express.json());
24
25app.use(express.static(path.join(__dirname, '../public')));
26
27// Middleware to protect routes
28const authenticateToken = (req: any, res: any, next: any) => {
29 const authHeader = req.headers.authorization;
30 const token = authHeader?.split(' ')[1];
31
32 if (!token) return res.sendStatus(401);
33
34 jwt.verify(token, JWT_SECRET, (err: any, user: any) => {
35 if (err) return res.sendStatus(403);
36 req.user = user;
37 next();
38 });
39};
40
41// Middleware to require admin access
42const requireAdmin = (req: any, res: any, next: any) => {
43 if (!req.user.isAdmin) {
44 return res.status(403).json({ error: 'Admin access required' });
45 }
46 next();
47};
48
49// --- Auth Routes ---
50
51app.post('/api/register', async (req, res) => {
52 const { email, password } = req.body;
53 const config = getConfig();
54
55 if (config.users.find((u) => u.email === email)) {
56 res.status(400).json({ error: 'User already exists' });
57 return;
58 }
59
60 const passwordHash = await bcrypt.hash(password, 10);
61 config.users.push({ email, passwordHash });
62 saveConfig(config);
63
64 res.json({ success: true });
65});
66
67app.post('/api/login', async (req, res) => {
68 const { email, password } = req.body;
69 const config = getConfig();
70 const user = config.users.find((u) => u.email === email);
71
72 if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
73 res.status(401).json({ error: 'Invalid credentials' });
74 return;
75 }
76
77 const userIndex = config.users.findIndex((u) => u.email === email);
78 const isAdmin = userIndex === 0;
79 const token = jwt.sign({ email: user.email, isAdmin }, JWT_SECRET, { expiresIn: '24h' });
80 res.json({ token, isAdmin });
81});
82
83app.get('/api/me', authenticateToken, (req: any, res) => {
84 res.json({ email: req.user.email, isAdmin: req.user.isAdmin });
85});
86
87// --- Mapping Routes ---
88
89app.get('/api/mappings', authenticateToken, (_req, res) => {
90 const config = getConfig();
91 res.json(config.mappings);
92});
93
94app.post('/api/mappings', authenticateToken, (req, res) => {
95 const { twitterUsername, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body;
96 const config = getConfig();
97
98 const newMapping = {
99 id: Math.random().toString(36).substring(7),
100 twitterUsername,
101 bskyIdentifier,
102 bskyPassword,
103 bskyServiceUrl: bskyServiceUrl || 'https://bsky.social',
104 enabled: true,
105 owner,
106 };
107
108 config.mappings.push(newMapping);
109 saveConfig(config);
110 res.json(newMapping);
111});
112
113app.delete('/api/mappings/:id', authenticateToken, (req, res) => {
114 const { id } = req.params;
115 const config = getConfig();
116 config.mappings = config.mappings.filter((m) => m.id !== id);
117 saveConfig(config);
118 res.json({ success: true });
119});
120
121app.delete('/api/mappings/:id/cache', authenticateToken, requireAdmin, (req, res) => {
122 const { id } = req.params;
123 const config = getConfig();
124 const mapping = config.mappings.find((m) => m.id === id);
125 if (!mapping) {
126 res.status(404).json({ error: 'Mapping not found' });
127 return;
128 }
129
130 const cachePath = path.join(__dirname, '../processed', `${mapping.twitterUsername.toLowerCase()}.json`);
131 if (fs.existsSync(cachePath)) {
132 fs.unlinkSync(cachePath);
133 res.json({ success: true, message: 'Cache cleared' });
134 } else {
135 res.json({ success: true, message: 'No cache found' });
136 }
137});
138
139// --- Twitter Config Routes (Admin Only) ---
140
141app.get('/api/twitter-config', authenticateToken, requireAdmin, (_req, res) => {
142 const config = getConfig();
143 res.json(config.twitter);
144});
145
146app.post('/api/twitter-config', authenticateToken, requireAdmin, (req, res) => {
147 const { authToken, ct0 } = req.body;
148 const config = getConfig();
149 config.twitter = { authToken, ct0 };
150 saveConfig(config);
151 res.json({ success: true });
152});
153
154// --- Status & Actions Routes ---
155
156app.get('/api/status', authenticateToken, (_req, res) => {
157 const config = getConfig();
158 const now = Date.now();
159 const checkIntervalMs = (config.checkIntervalMinutes || 5) * 60 * 1000;
160 const nextRunMs = Math.max(0, nextCheckTime - now);
161
162 res.json({
163 lastCheckTime,
164 nextCheckTime,
165 nextCheckMinutes: Math.ceil(nextRunMs / 60000),
166 checkIntervalMinutes: config.checkIntervalMinutes,
167 pendingBackfills,
168 });
169});
170
171app.post('/api/run-now', authenticateToken, (_req, res) => {
172 lastCheckTime = 0;
173 nextCheckTime = Date.now() + 1000;
174 res.json({ success: true, message: 'Check triggered' });
175});
176
177app.post('/api/backfill/:id', authenticateToken, requireAdmin, (req, res) => {
178 const { id } = req.params;
179 const config = getConfig();
180 const mapping = config.mappings.find((m) => m.id === id);
181
182 if (!mapping) {
183 res.status(404).json({ error: 'Mapping not found' });
184 return;
185 }
186
187 if (!pendingBackfills.includes(id)) {
188 pendingBackfills.push(id);
189 }
190
191 lastCheckTime = 0;
192 nextCheckTime = Date.now() + 1000;
193 res.json({ success: true, message: `Backfill queued for @${mapping.twitterUsername}` });
194});
195
196app.delete('/api/backfill/:id', authenticateToken, (req, res) => {
197 const { id } = req.params;
198 pendingBackfills = pendingBackfills.filter((bid) => bid !== id);
199 res.json({ success: true });
200});
201
202// Export for use by index.ts
203export function updateLastCheckTime() {
204 const config = getConfig();
205 lastCheckTime = Date.now();
206 nextCheckTime = lastCheckTime + (config.checkIntervalMinutes || 5) * 60 * 1000;
207}
208
209export function getPendingBackfills(): string[] {
210 return [...pendingBackfills];
211}
212
213export function getNextCheckTime(): number {
214 return nextCheckTime;
215}
216
217export function clearBackfill(id: string) {
218 pendingBackfills = pendingBackfills.filter((bid) => bid !== id);
219}
220
221// Serve the frontend for any other route (middleware approach for Express 5)
222app.use((_req, res) => {
223 res.sendFile(path.join(__dirname, '../public/index.html'));
224});
225
226export function startServer() {
227 app.listen(PORT, '0.0.0.0' as any, () => {
228 console.log(`🚀 Web interface running at http://localhost:${PORT}`);
229 console.log('📡 Accessible on your local network/Tailscale via your IP.');
230 });
231}