A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
at 336c1506cee87f0b593a2bb090bb66c8e9f87feb 231 lines 6.7 kB view raw
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}