diff --git a/server.cjs b/server.cjs index 10b7e10..f3e79a0 100644 --- a/server.cjs +++ b/server.cjs @@ -1369,6 +1369,23 @@ function isTurnstileSessionVerified(req) { } } +function protectedSiteSessionStatus(req) { + const turnstileVerified = isTurnstileSessionVerified(req) + const siteVerified = isSiteSessionVerified(req) + const canIssueSiteSession = turnstileVerified && !siteVerified && Boolean(SITE_SESSION_HMAC_KEY) + return { + turnstileVerified, + siteVerified, + canIssueSiteSession, + verified: turnstileVerified && siteVerified, + } +} + +function protectedSiteSessionGateState(req) { + const sessionStatus = protectedSiteSessionStatus(req) + return sessionStatus.verified || sessionStatus.canIssueSiteSession ? 'verified' : 'required' +} + function callTurnstileSiteverify(token, remoteIp, idempotencyKey) { return new Promise((resolve) => { const params = new URLSearchParams() @@ -2776,7 +2793,7 @@ function htmlWithSeo(req, data) { .replaceAll('__SEO_PUBLISHED_TIME__', escapeHtml(seo.publishedAt || '')) .replaceAll('__SEO_MODIFIED_TIME__', escapeHtml(seo.publishedAt || '')) .replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/', `
\n${routeFallbackHtml(seo)}\n
`) } @@ -2791,8 +2808,11 @@ function requestPathname(req) { function sendHtml(req, res, data, status) { const html = htmlWithSeo(req, data) const finalStatus = status ?? routeSeo(requestPathname(req)).status ?? 200 + const sessionStatus = protectedSiteSessionStatus(req) + const sessionCookie = sessionStatus.canIssueSiteSession ? buildSiteSessionCookie(req) : '' send(res, finalStatus, html, { ...securityHeaders(req, { html: true }), + ...(sessionCookie ? { 'set-cookie': sessionCookie } : {}), 'content-type': mimeTypes['.html'], 'cache-control': 'no-cache', }) @@ -3379,7 +3399,10 @@ const server = http.createServer((req, res) => { sendJson(res, 403, { error: 'Turnstile session check is restricted to this site' }) return } - sendJson(res, 200, { verified: isTurnstileSessionVerified(req) }) + const sessionStatus = protectedSiteSessionStatus(req) + const sessionCookie = sessionStatus.canIssueSiteSession ? buildSiteSessionCookie(req) : '' + const headers = sessionCookie ? { 'set-cookie': sessionCookie } : {} + sendJson(res, 200, { verified: sessionStatus.verified || Boolean(sessionCookie) }, headers) return } diff --git a/webhook.cjs b/webhook.cjs index 050a856..bf7484f 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -323,14 +323,8 @@ function runCapture(command, args, options = {}) { }) } -// Files that, when changed, require a fresh `npm ci`. package.json is included -// because npm ci refuses to run against a package.json/lockfile that are out of -// sync, so a change to either should trigger a reinstall. -const DEPENDENCY_FILES = ['package.json', 'package-lock.json'] - // Resolve the modules the build relies on. Returns the first missing module's -// name, or null if all are present. Used both to validate after an install and -// to decide whether a skipped install left an incomplete node_modules. +// name, or null if all are present. Used to validate after install. function missingBuildDependency() { try { require.resolve('@vitejs/plugin-react') @@ -351,49 +345,20 @@ function missingBuildDependency() { return null } -// True when any dependency file changed between previousHead and the current -// HEAD. Defaults to true (reinstall) whenever we can't determine the range, so -// an uncertain state never skips a needed install. -async function dependencyFilesChanged(previousHead) { - if (!validGitSha(previousHead)) return true - try { - const changed = await runCapture('git', [ - 'diff', - '--name-only', - `${previousHead}..HEAD`, - '--', - ...DEPENDENCY_FILES, - ]) - return changed.trim().length > 0 - } catch { - return true - } -} +async function ensureBuildDependencies() { + await run('npm', ['ci'], { + env: { + NODE_ENV: 'development', + npm_config_omit: '', + npm_config_production: 'false', + npm_config_fund: 'false', + npm_config_audit: 'false', + }, + }) -async function ensureBuildDependencies(previousHead) { - const depsChanged = await dependencyFilesChanged(previousHead) - // Skip the clean reinstall (which wipes node_modules and rebuilds native - // modules like better-sqlite3) only when no dependency files changed AND the - // existing node_modules already has everything the build needs. - const canSkip = !depsChanged && missingBuildDependency() === null - - if (canSkip) { - console.log('deploy step skipped: npm ci (no package.json/package-lock.json changes)') - } else { - await run('npm', ['ci'], { - env: { - NODE_ENV: 'development', - npm_config_omit: '', - npm_config_production: 'false', - npm_config_fund: 'false', - npm_config_audit: 'false', - }, - }) - - const missing = missingBuildDependency() - if (missing) { - throw new Error(`${missing} is missing after npm install; dependencies were not installed`) - } + const missing = missingBuildDependency() + if (missing) { + throw new Error(`${missing} is missing after npm install; dependencies were not installed`) } } @@ -643,10 +608,9 @@ async function deploy(push) { try { await notifyDeployStarted(push) - const previousHead = await runCapture('git', ['rev-parse', 'HEAD']).catch(() => null) await run('git', ['pull', '--ff-only']) diff = await deployDiff(push) - await ensureBuildDependencies(previousHead) + await ensureBuildDependencies() await run('npm', ['run', 'build', '--', '--outDir', '../dist-next']) validateBuiltDist() await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release']) @@ -738,43 +702,54 @@ const webhookServer = http.createServer((req, res) => { if (tooLarge) return const rawBody = Buffer.concat(chunks) const push = safeJsonParse(rawBody) + const event = String(req.headers['x-github-event'] || '') + const pushRef = String(push?.ref || '') + const repoFullName = String(push?.repository?.full_name || '') + const delivery = String(req.headers['x-github-delivery'] || 'unknown') if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) { + console.warn(`webhook delivery rejected: invalid signature delivery=${delivery} event=${event || '(missing)'}`) json(res, 401, { error: 'Invalid signature' }) return } - if (req.headers['x-github-event'] !== 'push') { + console.log(`webhook delivery received: delivery=${delivery} event=${event || '(missing)'} ref=${pushRef || '(missing)'} repo=${repoFullName || '(missing)'}`) + + if (event !== 'push') { + console.log(`webhook delivery skipped: delivery=${delivery} reason=non-push event=${event || '(missing)'}`) json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' }) return } - const pushRef = String(push?.ref || '') if (!ALLOWED_REFS.has(pushRef)) { + console.log(`webhook delivery skipped: delivery=${delivery} reason=ref ref=${pushRef || '(missing)'} allowed=${[...ALLOWED_REFS].join(',')}`) json(res, 202, { skipped: true, reason: `Ignoring ref ${pushRef || '(missing)'}` }) return } if (ALLOWED_REPOSITORY) { - const repoFullName = String(push?.repository?.full_name || '') if (repoFullName !== ALLOWED_REPOSITORY) { + console.log(`webhook delivery skipped: delivery=${delivery} reason=repository repo=${repoFullName || '(missing)'} allowed=${ALLOWED_REPOSITORY}`) json(res, 202, { skipped: true, reason: `Ignoring repository ${repoFullName || '(missing)'}` }) return } } if (push?.deleted) { + console.log(`webhook delivery skipped: delivery=${delivery} reason=branch-delete ref=${pushRef}`) json(res, 202, { skipped: true, reason: 'Ignoring branch-delete push' }) return } if (deploying) { queuedPush = push + console.log(`webhook deploy queued: delivery=${delivery} ref=${pushRef} repo=${repoFullName || '(missing)'}`) json(res, 202, { queued: true, deploying: true }) return } deploying = true + console.log(`webhook deploy accepted: delivery=${delivery} ref=${pushRef} repo=${repoFullName || '(missing)'} restart_targets=${RESTART_TARGETS.join(',') || 'none'}`) json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS }) runDeployQueue(push)