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.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Harden auth/session persistence and improve scheduler observability

jack 251f0c7d b55991bd

+335 -57
+11 -2
README.md
··· 137 137 cat > .env <<'EOF' 138 138 PORT=3000 139 139 JWT_SECRET=replace-with-a-strong-random-secret 140 + # Optional: auth token lifetime (jsonwebtoken format), default is 30d. 141 + # JWT_EXPIRES_IN=30d 142 + # Optional: comma-separated browser origins allowed to call the API. 143 + # Leave unset to allow all origins (default/backward-compatible). 144 + # CORS_ALLOWED_ORIGINS=https://your-tailnet-host.ts.net,https://localhost:3000 140 145 EOF 141 146 ``` 142 147 ··· 301 306 302 307 - `config.json`: mappings, credentials, users, app settings (sensitive; do not share) 303 308 - `data/database.sqlite`: processed tweet history and metadata 304 - - `.env`: runtime environment variables (`PORT`, `JWT_SECRET`, optional overrides) 309 + - `data/.jwt-secret`: auto-generated local JWT signing key when `JWT_SECRET` is not set (sensitive; keep private) 310 + - `.env`: runtime environment variables (`PORT`, `JWT_SECRET`, `JWT_EXPIRES_IN`, optional overrides) 305 311 306 312 Security notes: 307 313 ··· 312 318 - admins can grant fine-grained permissions (view all mappings, manage groups, queue backfills, run-now, etc.) 313 319 - only admins can view or edit Twitter/AI provider credentials 314 320 - admin user management never exposes other users' password hashes in the UI 315 - - if `JWT_SECRET` is missing, server falls back to an insecure default; set your own secret in `.env` 321 + - if `JWT_SECRET` is missing, server generates and persists a strong secret in `data/.jwt-secret` so sessions survive restarts 322 + - set `JWT_SECRET` in `.env` if you prefer explicit secret management across hosts 323 + - auth tokens default to `30d` expiry (`JWT_EXPIRES_IN`), configurable via `.env` 324 + - auth endpoints (`/api/login`, `/api/register`) are rate-limited per IP to reduce brute-force risk 316 325 - prefer Bluesky app passwords (not your full account password) 317 326 318 327 ### Multi-User Access Control
+51 -9
src/index.ts
··· 2029 2029 const activeTasks = new Map<string, Promise<void>>(); 2030 2030 const DEFAULT_BACKFILL_ACCOUNT_TIMEOUT_MS = 2 * 60 * 1000; 2031 2031 2032 + const describeError = (error: unknown): string => { 2033 + if (error instanceof Error) { 2034 + return error.message; 2035 + } 2036 + if (typeof error === 'string') { 2037 + return error; 2038 + } 2039 + try { 2040 + return JSON.stringify(error); 2041 + } catch { 2042 + return String(error); 2043 + } 2044 + }; 2045 + 2046 + const getMappingLogPrefix = (mapping: AccountMapping): string => { 2047 + const owner = mapping.owner?.trim() || 'unknown-owner'; 2048 + const creator = mapping.createdByUserId || 'unknown-user'; 2049 + return `[mapping:${mapping.id}] [owner:${owner}] [creator:${creator}] [target:${mapping.bskyIdentifier}]`; 2050 + }; 2051 + 2032 2052 const resolveBackfillAccountTimeoutMs = (): number => { 2033 2053 const raw = Number(process.env.BACKFILL_ACCOUNT_TIMEOUT_MS); 2034 2054 if (Number.isFinite(raw) && raw >= 15_000) { ··· 2055 2075 } 2056 2076 2057 2077 async function runAccountTask(mapping: AccountMapping, backfillRequest?: PendingBackfill, dryRun = false) { 2058 - if (activeTasks.has(mapping.id)) return; // Already running 2078 + const logPrefix = getMappingLogPrefix(mapping); 2079 + const existingTask = activeTasks.get(mapping.id); 2080 + if (existingTask) { 2081 + console.log(`${logPrefix} ⏳ Task already in progress. Reusing active run.`); 2082 + return existingTask; 2083 + } 2059 2084 2060 2085 const task = (async () => { 2086 + let checkedSources = 0; 2087 + let sourceErrors = 0; 2088 + const taskMode = backfillRequest ? 'backfill' : 'scheduled'; 2089 + console.log( 2090 + `${logPrefix} ▶️ Starting ${taskMode} task for ${mapping.twitterUsernames.length} source account(s).`, 2091 + ); 2092 + 2061 2093 try { 2062 2094 const backfillReq = backfillRequest ?? getPendingBackfills().find((b) => b.id === mapping.id); 2063 2095 2064 2096 if (mapping.twitterUsernames.length === 0) { 2065 - console.warn(`[${mapping.bskyIdentifier}] ⚠️ No Twitter usernames configured. Skipping mapping.`); 2097 + console.warn(`${logPrefix} ⚠️ No Twitter usernames configured. Skipping mapping.`); 2066 2098 if (backfillReq) { 2067 2099 clearBackfill(mapping.id, backfillReq.requestId); 2068 2100 updateAppStatus({ ··· 2080 2112 2081 2113 const agent = await getAgent(mapping); 2082 2114 if (!agent) { 2115 + console.warn(`${logPrefix} ⚠️ Unable to authenticate Bluesky account. Skipping task.`); 2083 2116 if (backfillReq) { 2084 - console.warn(`[${mapping.bskyIdentifier}] ⚠️ Backfill aborted: unable to authenticate Bluesky account.`); 2085 2117 clearBackfill(mapping.id, backfillReq.requestId); 2086 2118 updateAppStatus({ 2087 2119 state: 'idle', ··· 2104 2136 const accountCount = mapping.twitterUsernames.length; 2105 2137 const estimatedTotalTweets = accountCount * limit; 2106 2138 console.log( 2107 - `[${mapping.bskyIdentifier}] Running backfill for ${mapping.twitterUsernames.length} accounts (limit ${limit})...`, 2139 + `${logPrefix} Running backfill for ${mapping.twitterUsernames.length} accounts (limit ${limit})...`, 2108 2140 ); 2109 2141 updateAppStatus({ 2110 2142 state: 'backfilling', ··· 2125 2157 ? true 2126 2158 : getPendingBackfills().some((b) => b.id === mapping.id && b.requestId === backfillReq.requestId); 2127 2159 if (!stillPending) { 2128 - console.log(`[${mapping.bskyIdentifier}] 🛑 Backfill request replaced; stopping.`); 2160 + console.log(`${logPrefix} 🛑 Backfill request replaced; stopping.`); 2129 2161 break; 2130 2162 } 2131 2163 2132 2164 try { 2165 + checkedSources += 1; 2133 2166 updateAppStatus({ 2134 2167 state: 'backfilling', 2135 2168 currentAccount: twitterUsername, ··· 2154 2187 backfillRequestId: backfillReq.requestId, 2155 2188 }); 2156 2189 } catch (err) { 2157 - console.error(`❌ Error backfilling ${twitterUsername}:`, err); 2190 + sourceErrors += 1; 2191 + console.error( 2192 + `${logPrefix} ❌ Error backfilling @${twitterUsername}: ${describeError(err)}`, 2193 + ); 2158 2194 } 2159 2195 } 2160 2196 clearBackfill(mapping.id, backfillReq.requestId); ··· 2166 2202 backfillMappingId: undefined, 2167 2203 backfillRequestId: undefined, 2168 2204 }); 2169 - console.log(`[${mapping.bskyIdentifier}] Backfill complete.`); 2205 + console.log(`${logPrefix} Backfill complete.`); 2170 2206 } else { 2171 2207 updateAppStatus({ backfillMappingId: undefined, backfillRequestId: undefined }); 2172 2208 ··· 2176 2212 2177 2213 for (const twitterUsername of mapping.twitterUsernames) { 2178 2214 try { 2215 + checkedSources += 1; 2179 2216 console.log(`[${twitterUsername}] 🏁 Starting check for new tweets...`); 2180 2217 updateAppStatus({ 2181 2218 state: 'checking', ··· 2197 2234 console.log(`[${twitterUsername}] 📥 Fetched ${tweets.length} tweets.`); 2198 2235 await processTweets(agent, twitterUsername, mapping.bskyIdentifier, tweets, dryRun); 2199 2236 } catch (err) { 2200 - console.error(`❌ Error checking ${twitterUsername}:`, err); 2237 + sourceErrors += 1; 2238 + console.error(`${logPrefix} ❌ Error checking @${twitterUsername}: ${describeError(err)}`); 2201 2239 } 2202 2240 } 2203 2241 } 2204 2242 } catch (err) { 2205 - console.error(`Error processing mapping ${mapping.bskyIdentifier}:`, err); 2243 + sourceErrors += 1; 2244 + console.error(`${logPrefix} ❌ Mapping task failed: ${describeError(err)}`); 2206 2245 } finally { 2207 2246 activeTasks.delete(mapping.id); 2247 + console.log( 2248 + `${logPrefix} ✅ Task finished. Sources checked=${checkedSources}, source errors=${sourceErrors}.`, 2249 + ); 2208 2250 } 2209 2251 })(); 2210 2252
+273 -46
src/server.ts
··· 1 1 import { execSync, spawn } from 'node:child_process'; 2 - import { randomUUID } from 'node:crypto'; 2 + import { randomBytes, randomUUID } from 'node:crypto'; 3 3 import fs from 'node:fs'; 4 4 import path from 'node:path'; 5 5 import { fileURLToPath } from 'node:url'; ··· 7 7 import bcrypt from 'bcryptjs'; 8 8 import cors from 'cors'; 9 9 import express from 'express'; 10 - import jwt from 'jsonwebtoken'; 10 + import jwt, { type SignOptions } from 'jsonwebtoken'; 11 11 import { deleteAllPosts } from './bsky.js'; 12 12 import { 13 13 ADMIN_USER_PERMISSIONS, ··· 29 29 const app = express(); 30 30 const PORT = Number(process.env.PORT) || 3000; 31 31 const HOST = (process.env.HOST || process.env.BIND_HOST || '0.0.0.0').trim() || '0.0.0.0'; 32 - const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret'; 33 32 const APP_ROOT_DIR = path.join(__dirname, '..'); 33 + const JWT_SECRET_FILE_PATH = path.join(APP_ROOT_DIR, 'data', '.jwt-secret'); 34 + const jwtSecretFromEnv = process.env.JWT_SECRET?.trim(); 35 + const JWT_EXPIRES_IN = ((process.env.JWT_EXPIRES_IN || '30d').trim() || '30d') as SignOptions['expiresIn']; 34 36 const WEB_DIST_DIR = path.join(APP_ROOT_DIR, 'web', 'dist'); 35 37 const LEGACY_PUBLIC_DIR = path.join(APP_ROOT_DIR, 'public'); 36 38 const PACKAGE_JSON_PATH = path.join(APP_ROOT_DIR, 'package.json'); ··· 43 45 const RESERVED_UNGROUPED_KEY = 'ungrouped'; 44 46 const SERVER_STARTED_AT = Date.now(); 45 47 const PASSWORD_MIN_LENGTH = 8; 48 + const AUTH_RATE_WINDOW_MS = 15 * 60 * 1000; 49 + const AUTH_RATE_MAX_ATTEMPTS = 30; 50 + const APPVIEW_POST_CHUNK_SIZE = 10; 51 + const APPVIEW_PROFILE_CHUNK_SIZE = 25; 52 + const APPVIEW_MAX_ATTEMPTS = 2; 53 + const APPVIEW_RETRY_DELAY_MS = 700; 54 + 55 + function loadPersistedJwtSecret(): string | undefined { 56 + if (!fs.existsSync(JWT_SECRET_FILE_PATH)) { 57 + return undefined; 58 + } 59 + 60 + try { 61 + const secret = fs.readFileSync(JWT_SECRET_FILE_PATH, 'utf8').trim(); 62 + if (secret.length >= 32) { 63 + return secret; 64 + } 65 + console.warn(`⚠️ Ignoring weak JWT secret in ${JWT_SECRET_FILE_PATH}. Regenerating.`); 66 + return undefined; 67 + } catch (error) { 68 + console.warn( 69 + `⚠️ Failed reading JWT secret file at ${JWT_SECRET_FILE_PATH}: ${(error as Error).message}. Regenerating.`, 70 + ); 71 + return undefined; 72 + } 73 + } 74 + 75 + function persistJwtSecret(secret: string): void { 76 + fs.mkdirSync(path.dirname(JWT_SECRET_FILE_PATH), { recursive: true }); 77 + fs.writeFileSync(JWT_SECRET_FILE_PATH, `${secret}\n`, { mode: 0o600 }); 78 + try { 79 + fs.chmodSync(JWT_SECRET_FILE_PATH, 0o600); 80 + } catch { 81 + // Best effort on non-POSIX filesystems. 82 + } 83 + } 84 + 85 + function resolveJwtSecret(): string { 86 + if (jwtSecretFromEnv) { 87 + if (jwtSecretFromEnv.length < 32) { 88 + console.warn('⚠️ JWT_SECRET is shorter than 32 characters. Use a longer value for stronger signing security.'); 89 + } 90 + return jwtSecretFromEnv; 91 + } 92 + 93 + const persisted = loadPersistedJwtSecret(); 94 + if (persisted) { 95 + return persisted; 96 + } 97 + 98 + const generated = randomBytes(48).toString('hex'); 99 + persistJwtSecret(generated); 100 + console.warn( 101 + `⚠️ JWT_SECRET not set. Generated persistent signing secret at ${JWT_SECRET_FILE_PATH}. Keep this file private.`, 102 + ); 103 + return generated; 104 + } 105 + 106 + const JWT_SECRET = resolveJwtSecret(); 46 107 47 108 interface CacheEntry<T> { 48 109 value: T; ··· 153 214 return Date.now(); 154 215 } 155 216 217 + const parseAllowedOrigins = (): Set<string> => { 218 + const raw = process.env.CORS_ALLOWED_ORIGINS || process.env.CORS_ORIGIN || ''; 219 + const origins = raw 220 + .split(',') 221 + .map((entry) => entry.trim()) 222 + .filter((entry) => entry.length > 0); 223 + return new Set(origins); 224 + }; 225 + 226 + const allowedOrigins = parseAllowedOrigins(); 227 + 228 + interface RateLimitBucket { 229 + count: number; 230 + resetAt: number; 231 + } 232 + 233 + const authRateBuckets = new Map<string, RateLimitBucket>(); 234 + 235 + const getRequestIp = (req: any): string => { 236 + const forwarded = req.headers?.['x-forwarded-for']; 237 + if (typeof forwarded === 'string' && forwarded.trim().length > 0) { 238 + const [first] = forwarded.split(','); 239 + if (first && first.trim().length > 0) { 240 + return first.trim(); 241 + } 242 + } 243 + if (typeof req.ip === 'string' && req.ip.length > 0) { 244 + return req.ip; 245 + } 246 + if (typeof req.socket?.remoteAddress === 'string' && req.socket.remoteAddress.length > 0) { 247 + return req.socket.remoteAddress; 248 + } 249 + return 'unknown'; 250 + }; 251 + 252 + const authRateLimiter = (req: any, res: any, next: any) => { 253 + const now = nowMs(); 254 + if (authRateBuckets.size > 5000) { 255 + for (const [bucketKey, bucketValue] of authRateBuckets.entries()) { 256 + if (bucketValue.resetAt <= now) { 257 + authRateBuckets.delete(bucketKey); 258 + } 259 + } 260 + } 261 + 262 + const ip = getRequestIp(req); 263 + const key = `auth:${ip}`; 264 + const bucket = authRateBuckets.get(key); 265 + 266 + if (!bucket || bucket.resetAt <= now) { 267 + authRateBuckets.set(key, { 268 + count: 1, 269 + resetAt: now + AUTH_RATE_WINDOW_MS, 270 + }); 271 + next(); 272 + return; 273 + } 274 + 275 + if (bucket.count >= AUTH_RATE_MAX_ATTEMPTS) { 276 + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); 277 + res.setHeader('Retry-After', String(retryAfterSeconds)); 278 + res.status(429).json({ 279 + error: `Too many authentication attempts. Try again in about ${retryAfterSeconds} seconds.`, 280 + }); 281 + return; 282 + } 283 + 284 + bucket.count += 1; 285 + authRateBuckets.set(key, bucket); 286 + next(); 287 + }; 288 + 156 289 function buildPostUrl(identifier: string, uri?: string): string | undefined { 157 290 if (!uri) return undefined; 158 291 const rkey = uri.split('/').filter(Boolean).pop(); ··· 317 450 return []; 318 451 } 319 452 453 + const RETRYABLE_APPVIEW_CODES = new Set(['ETIMEDOUT', 'ECONNABORTED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN']); 454 + 455 + function describeAxiosError(error: unknown): string { 456 + if (axios.isAxiosError(error)) { 457 + const details = [error.message]; 458 + const status = error.response?.status; 459 + const code = error.code; 460 + const causeCode = 461 + typeof (error as { cause?: { code?: unknown } }).cause?.code === 'string' 462 + ? (error as { cause?: { code?: string } }).cause?.code 463 + : undefined; 464 + 465 + if (typeof status === 'number') { 466 + details.push(`status=${status}`); 467 + } 468 + if (typeof code === 'string') { 469 + details.push(`code=${code}`); 470 + } 471 + if (typeof causeCode === 'string' && causeCode !== code) { 472 + details.push(`cause=${causeCode}`); 473 + } 474 + return details.join(', '); 475 + } 476 + 477 + if (error instanceof Error) { 478 + return error.message; 479 + } 480 + return String(error); 481 + } 482 + 483 + function isRetryableAppviewError(error: unknown): boolean { 484 + if (!axios.isAxiosError(error)) { 485 + return false; 486 + } 487 + 488 + const status = error.response?.status; 489 + if (typeof status === 'number' && (status >= 500 || status === 429)) { 490 + return true; 491 + } 492 + 493 + const code = error.code; 494 + if (typeof code === 'string' && RETRYABLE_APPVIEW_CODES.has(code)) { 495 + return true; 496 + } 497 + 498 + const causeCode = (error as { cause?: { code?: unknown } }).cause?.code; 499 + if (typeof causeCode === 'string' && RETRYABLE_APPVIEW_CODES.has(causeCode)) { 500 + return true; 501 + } 502 + 503 + return false; 504 + } 505 + 506 + const sleep = (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)); 507 + 508 + async function fetchAppview(pathname: string, params: URLSearchParams, context: string): Promise<any | null> { 509 + const url = `${BSKY_APPVIEW_URL}${pathname}?${params.toString()}`; 510 + for (let attempt = 1; attempt <= APPVIEW_MAX_ATTEMPTS; attempt += 1) { 511 + try { 512 + const response = await axios.get(url, { timeout: 12_000 }); 513 + return response.data; 514 + } catch (error) { 515 + const retryable = isRetryableAppviewError(error); 516 + const canRetry = retryable && attempt < APPVIEW_MAX_ATTEMPTS; 517 + console.warn( 518 + `[AppView] ${context} failed (attempt ${attempt}/${APPVIEW_MAX_ATTEMPTS}): ${describeAxiosError(error)}${canRetry ? '. Retrying...' : ''}`, 519 + ); 520 + if (!canRetry) { 521 + return null; 522 + } 523 + await sleep(APPVIEW_RETRY_DELAY_MS * attempt); 524 + } 525 + } 526 + return null; 527 + } 528 + 320 529 async function fetchPostViewsByUri(uris: string[]): Promise<Map<string, any>> { 321 530 const result = new Map<string, any>(); 322 531 const uniqueUris = [...new Set(uris.filter((uri) => typeof uri === 'string' && uri.length > 0))]; ··· 331 540 pendingUris.push(uri); 332 541 } 333 542 334 - for (const chunk of chunkArray(pendingUris, 25)) { 543 + for (const chunk of chunkArray(pendingUris, APPVIEW_POST_CHUNK_SIZE)) { 335 544 if (chunk.length === 0) continue; 336 545 const params = new URLSearchParams(); 337 546 for (const uri of chunk) params.append('uris', uri); 338 547 339 - try { 340 - const response = await axios.get(`${BSKY_APPVIEW_URL}/xrpc/app.bsky.feed.getPosts?${params.toString()}`, { 341 - timeout: 12_000, 548 + const responseData = await fetchAppview( 549 + '/xrpc/app.bsky.feed.getPosts', 550 + params, 551 + `getPosts chunk=${chunk.length}`, 552 + ); 553 + if (!responseData) { 554 + continue; 555 + } 556 + 557 + const posts = Array.isArray(responseData.posts) ? responseData.posts : []; 558 + for (const post of posts) { 559 + const uri = typeof post?.uri === 'string' ? post.uri : undefined; 560 + if (!uri) continue; 561 + postViewCache.set(uri, { 562 + value: post, 563 + expiresAt: nowMs() + POST_VIEW_CACHE_TTL_MS, 342 564 }); 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); 565 + result.set(uri, post); 355 566 } 356 567 } 357 568 ··· 372 583 pendingActors.push(actor); 373 584 } 374 585 375 - for (const chunk of chunkArray(pendingActors, 25)) { 586 + for (const chunk of chunkArray(pendingActors, APPVIEW_PROFILE_CHUNK_SIZE)) { 376 587 if (chunk.length === 0) continue; 377 588 const params = new URLSearchParams(); 378 589 for (const actor of chunk) params.append('actors', actor); 379 590 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 - }; 591 + const responseData = await fetchAppview( 592 + '/xrpc/app.bsky.actor.getProfiles', 593 + params, 594 + `getProfiles chunk=${chunk.length}`, 595 + ); 596 + if (!responseData) { 597 + continue; 598 + } 599 + 600 + const profiles = Array.isArray(responseData.profiles) ? responseData.profiles : []; 601 + for (const profile of profiles) { 602 + const view: BskyProfileView = { 603 + did: typeof profile?.did === 'string' ? profile.did : undefined, 604 + handle: typeof profile?.handle === 'string' ? profile.handle : undefined, 605 + displayName: typeof profile?.displayName === 'string' ? profile.displayName : undefined, 606 + avatar: typeof profile?.avatar === 'string' ? profile.avatar : undefined, 607 + }; 392 608 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); 609 + const keys = [ 610 + typeof view.handle === 'string' ? normalizeActor(view.handle) : '', 611 + typeof view.did === 'string' ? normalizeActor(view.did) : '', 612 + ].filter((key) => key.length > 0); 397 613 398 - for (const key of keys) { 399 - profileCache.set(key, { value: view, expiresAt: nowMs() + PROFILE_CACHE_TTL_MS }); 400 - result[key] = view; 401 - } 614 + for (const key of keys) { 615 + profileCache.set(key, { value: view, expiresAt: nowMs() + PROFILE_CACHE_TTL_MS }); 616 + result[key] = view; 402 617 } 403 - } catch (error) { 404 - console.warn('Failed to fetch profiles from Bluesky appview:', error); 405 618 } 406 619 } 407 620 ··· 508 721 signalSchedulerWake(); 509 722 } 510 723 511 - app.use(cors()); 724 + if (allowedOrigins.size === 0) { 725 + app.use(cors()); 726 + } else { 727 + app.use( 728 + cors({ 729 + origin: (origin, callback) => { 730 + if (!origin) { 731 + callback(null, true); 732 + return; 733 + } 734 + callback(null, allowedOrigins.has(origin)); 735 + }, 736 + }), 737 + ); 738 + } 512 739 app.use(express.json()); 513 740 514 741 app.use(express.static(staticAssetsDir)); ··· 631 858 username: user.username, 632 859 }, 633 860 JWT_SECRET, 634 - { expiresIn: '24h' }, 861 + { expiresIn: JWT_EXPIRES_IN }, 635 862 ); 636 863 637 864 const findUserByIdentifier = (config: AppConfig, identifier: string): WebUser | undefined => { ··· 1062 1289 res.json({ bootstrapOpen: config.users.length === 0 }); 1063 1290 }); 1064 1291 1065 - app.post('/api/register', async (req, res) => { 1292 + app.post('/api/register', authRateLimiter, async (req, res) => { 1066 1293 const config = getConfig(); 1067 1294 if (config.users.length > 0) { 1068 1295 res.status(403).json({ error: 'Registration is disabled. Ask an admin to create your account.' }); ··· 1117 1344 res.json({ success: true }); 1118 1345 }); 1119 1346 1120 - app.post('/api/login', async (req, res) => { 1347 + app.post('/api/login', authRateLimiter, async (req, res) => { 1121 1348 const password = req.body?.password; 1122 1349 const identifier = normalizeOptionalString(req.body?.identifier) ?? normalizeOptionalString(req.body?.email); 1123 1350 if (!identifier || typeof password !== 'string') {