Publish Container / docker (push) Failing after 10m40s
- Remove unused prop-types dep, deprecated useOutboundLinkTracking export - Fix missing linkClicks key in analyticsEvents initial state (B-01) - Export ADMIN_SESSION_COOKIE from auth.js; remove local copy in admin-auth.js (D-02) - Remove cookieFlags() wrapper in auth.js and study-helpers.js; use cookieSecureFlag() directly (U-04) - Remove ADMIN_SESSION_TTL_MS duplicate from config.js (D-01) - Remove DEFAULT_SEO/DEFAULT_LEGAL/DEFAULT_REDIRECT_RULES/DEFAULT_PODCAST_FEATURED_LINKS/DEFAULT_PUBLISH_STATE duplicates from config.js; re-export from helpers.js (D-04, fixes sitemapPaths divergence) - Fix email.js hardcoded fallback strings to use DEFAULT_RESEND_TO/DEFAULT_RESEND_REPLY_TO (M-02) - Remove duplicate normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules bodies from study-helpers.js; re-export from helpers.js (D-03) - Fix pruneStatsByDays to import VISITOR_RETENTION_DAYS_DEFAULT from config.js (B-02) - Fix createTitusDownloadToken to use DOWNLOAD_TOKEN_TTL_MS constant (B-03) - Remove normalizeMessageType duplicate from study-helpers.js (U-03/D-08) - Remove dead wantsWelcome variable in contact.js; use shouldSendWelcome (B-04) - Replace dynamic imports in contact.js with static imports (B-07) - Remove dead wantsWelcome=true in study-auth.js (B-05) - Merge duplicate import blocks in analytics.js and public.js (D-05, D-06) - Replace dynamic readFile re-import in nuggets-host-upload.js (B-06) - Replace hardcoded SITE_URL fallbacks with getCanonicalBaseUrl() (M-06) - Replace raw resend.emails.send in study-account.js with sendResendEmailWithRetry (M-01) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
5.1 KiB
JavaScript
165 lines
5.1 KiB
JavaScript
import rateLimit from 'express-rate-limit'
|
|
import qrcode from 'qrcode'
|
|
import { parseCookies } from '../helpers.js'
|
|
import { APP_VERSION, GIT_COMMIT } from '../config.js'
|
|
import {
|
|
ADMIN_SESSION_COOKIE,
|
|
isAdminPasswordConfigured,
|
|
isValidAdminSession,
|
|
requireAdminAuth,
|
|
setAdminSessionCookie,
|
|
clearAdminSessionCookie,
|
|
createAdminSession,
|
|
deleteAdminSession,
|
|
isAdminPasswordValid,
|
|
isTotpEnabled,
|
|
loadTotpState,
|
|
saveTotpState,
|
|
generateTotpSecret,
|
|
getTotpUri,
|
|
verifyTotpCode,
|
|
generateRecoveryCodes,
|
|
hashRecoveryCode,
|
|
consumeRecoveryCode,
|
|
createPendingSession,
|
|
consumePendingSession,
|
|
} from '../auth.js'
|
|
|
|
const loginRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' },
|
|
skipSuccessfulRequests: true,
|
|
})
|
|
|
|
export function register(app) {
|
|
app.get('/api/admin-auth/status', async (req, res) => {
|
|
res.json({
|
|
authenticated: isValidAdminSession(req),
|
|
configured: isAdminPasswordConfigured(),
|
|
totpEnabled: await isTotpEnabled(),
|
|
version: APP_VERSION,
|
|
commit: GIT_COMMIT,
|
|
})
|
|
})
|
|
|
|
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
|
|
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
|
|
|
if (!isAdminPasswordConfigured()) {
|
|
res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
|
|
return
|
|
}
|
|
|
|
if (!isAdminPasswordValid(password)) {
|
|
res.status(401).json({ message: 'Invalid password.' })
|
|
return
|
|
}
|
|
|
|
const totpOn = await isTotpEnabled()
|
|
if (totpOn) {
|
|
const pendingToken = createPendingSession()
|
|
res.json({ totpRequired: true, pendingToken })
|
|
return
|
|
}
|
|
|
|
const sessionToken = createAdminSession()
|
|
setAdminSessionCookie(res, sessionToken)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
|
|
const { pendingToken, code } = req.body ?? {}
|
|
|
|
if (!consumePendingSession(pendingToken)) {
|
|
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
|
|
return
|
|
}
|
|
|
|
const totpState = await loadTotpState()
|
|
if (!totpState?.secret || !totpState?.verified) {
|
|
res.status(400).json({ message: 'TOTP is not configured.' })
|
|
return
|
|
}
|
|
|
|
const codeStr = typeof code === 'string' ? code.trim() : ''
|
|
|
|
if (verifyTotpCode(totpState.secret, codeStr)) {
|
|
const sessionToken = createAdminSession()
|
|
setAdminSessionCookie(res, sessionToken)
|
|
res.json({ ok: true })
|
|
return
|
|
}
|
|
|
|
if (consumeRecoveryCode(totpState, codeStr)) {
|
|
await saveTotpState(totpState)
|
|
const sessionToken = createAdminSession()
|
|
setAdminSessionCookie(res, sessionToken)
|
|
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: totpState.hashedRecoveryCodes.length })
|
|
return
|
|
}
|
|
|
|
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
|
|
})
|
|
|
|
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
|
|
const secret = generateTotpSecret()
|
|
const uri = getTotpUri(secret)
|
|
const qrDataUrl = await qrcode.toDataURL(uri)
|
|
const existing = await loadTotpState()
|
|
await saveTotpState({ ...existing, secret, verified: false })
|
|
res.json({ qrDataUrl, secret })
|
|
})
|
|
|
|
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
|
|
const { code } = req.body ?? {}
|
|
const totpState = await loadTotpState()
|
|
|
|
if (!totpState?.secret) {
|
|
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
|
|
return
|
|
}
|
|
|
|
if (!verifyTotpCode(totpState.secret, typeof code === 'string' ? code.trim() : '')) {
|
|
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
|
|
return
|
|
}
|
|
|
|
const recoveryCodes = generateRecoveryCodes()
|
|
await saveTotpState({
|
|
secret: totpState.secret,
|
|
verified: true,
|
|
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
|
|
enabledAt: new Date().toISOString(),
|
|
})
|
|
|
|
res.json({ ok: true, recoveryCodes })
|
|
})
|
|
|
|
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
|
|
await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
|
|
const totpState = await loadTotpState()
|
|
if (!totpState?.secret || !totpState?.verified) {
|
|
res.status(400).json({ message: 'TOTP is not enabled.' })
|
|
return
|
|
}
|
|
const recoveryCodes = generateRecoveryCodes()
|
|
await saveTotpState({ ...totpState, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
|
|
res.json({ ok: true, recoveryCodes })
|
|
})
|
|
|
|
app.post('/api/admin-auth/logout', (req, res) => {
|
|
const cookies = parseCookies(req.headers.cookie)
|
|
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
|
deleteAdminSession(sessionToken)
|
|
clearAdminSessionCookie(res)
|
|
res.json({ ok: true })
|
|
})
|
|
}
|