diff --git a/.env.prod.example b/.env.prod.example index 5926b55..58f13dc 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -5,7 +5,7 @@ PUBLIC_TURNSTILE_SITE_KEY=REPLACE_WITH_PRODUCTION_TURNSTILE_SITE_KEY # GAS values used by `npm run gas:push:production` and `npm run gas:deploy:production` GAS_SCRIPT_ID=REPLACE_WITH_PRODUCTION_GAS_SCRIPT_ID GAS_TURNSTILE_SECRET=REPLACE_WITH_PRODUCTION_TURNSTILE_SECRET -GAS_PUBLIC_SITE_URL=https://t.purr.tw +GAS_PUBLIC_SITE_URL=https://s.purr.tw GAS_ENFORCE_CAPTCHA=true GAS_ENFORCE_ACCESS_CONTROL=true GAS_DEPLOYMENT_DESCRIPTION=shortyou-production diff --git a/.github/workflows/gas-cicd.yml b/.github/workflows/gas-cicd.yml index b07fc7a..dcefefb 100644 --- a/.github/workflows/gas-cicd.yml +++ b/.github/workflows/gas-cicd.yml @@ -4,12 +4,24 @@ on: pull_request: paths: - 'gas/**' + - 'scripts/build-gas.mjs' + - 'scripts/env-utils.mjs' + - 'scripts/push-gas.mjs' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.gas.json' - '.github/workflows/gas-cicd.yml' push: branches: - main paths: - 'gas/**' + - 'scripts/build-gas.mjs' + - 'scripts/env-utils.mjs' + - 'scripts/push-gas.mjs' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.gas.json' - '.github/workflows/gas-cicd.yml' workflow_dispatch: @@ -53,6 +65,15 @@ jobs: runs-on: ubuntu-latest needs: validate environment: production + env: + PUBLIC_API_URL: ${{ vars.PUBLIC_API_URL || secrets.PUBLIC_API_URL }} + GAS_PUBLIC_SITE_URL: ${{ vars.GAS_PUBLIC_SITE_URL }} + GAS_ENFORCE_CAPTCHA: ${{ vars.GAS_ENFORCE_CAPTCHA || 'true' }} + GAS_ENFORCE_ACCESS_CONTROL: ${{ vars.GAS_ENFORCE_ACCESS_CONTROL || 'true' }} + GAS_DEPLOYMENT_DESCRIPTION: ${{ vars.GAS_DEPLOYMENT_DESCRIPTION || 'shortyou-prod' }} + GAS_SCRIPT_ID: ${{ secrets.GAS_SCRIPT_ID }} + GAS_DEPLOYMENT_ID: ${{ secrets.GAS_DEPLOYMENT_ID }} + GAS_TURNSTILE_SECRET: ${{ secrets.GAS_TURNSTILE_SECRET }} steps: - name: Checkout uses: actions/checkout@v4 @@ -65,38 +86,47 @@ jobs: - name: Install dependencies run: npm ci - - name: Build GAS backend (TypeScript -> JavaScript) - run: npm run build - - name: Install clasp run: npm i -g @google/clasp - name: Prepare clasp auth env: CLASPRC_JSON: ${{ secrets.CLASPRC_JSON }} - GAS_SCRIPT_ID: ${{ secrets.GAS_SCRIPT_ID }} run: | - if [ -z "$CLASPRC_JSON" ] || [ -z "$GAS_SCRIPT_ID" ]; then - echo "Missing required secrets: CLASPRC_JSON / GAS_SCRIPT_ID" + if [ -z "$CLASPRC_JSON" ]; then + echo "Missing required secret: CLASPRC_JSON" exit 1 fi printf '%s' "$CLASPRC_JSON" > "$HOME/.clasprc.json" - cat > gas/.clasp.json < sheetRepository.disableClient(normalizedCode)); + const { repository } = buildRuntimeContext_(); + return repository.withScriptLock(() => repository.disableClient(normalizedCode)); } function rotateClientToken(clientCode: string): ApiResult { const normalizedCode = InputNormalizer.text(clientCode); if (!normalizedCode) return { success: false, result: '', error: 'missing_client_code' }; - return sheetRepository.withScriptLock(() => sheetRepository.rotateClientToken(normalizedCode)); + const { repository } = buildRuntimeContext_(); + return repository.withScriptLock(() => repository.rotateClientToken(normalizedCode)); } function issueCapabilityLink( @@ -162,9 +174,8 @@ function issueCapabilityLink( dailyQuota: number, note: string ): ApiResult { - const result = sheetRepository.withScriptLock(() => - sheetRepository.issueCapabilityLink(ownerName, expiresAtIso, dailyQuota, note) - ); + const { repository } = buildRuntimeContext_(); + const result = repository.withScriptLock(() => repository.issueCapabilityLink(ownerName, expiresAtIso, dailyQuota, note)); // Apps Script 編輯器手動執行時,回傳值不一定會直接顯示;同步寫入執行記錄方便複製完整 link。 Logger.log(JSON.stringify(result)); @@ -172,7 +183,7 @@ function issueCapabilityLink( } function query(alias: string): GoogleAppsScript.Content.TextOutput { - return json_(shortUrlService.resolve(alias)); + return json_(buildRuntimeContext_().shortUrlService.resolve(alias)); } function add( @@ -190,7 +201,7 @@ function add( ip, capabilityToken: id }; - return apiController.handlePost({ + return buildRuntimeContext_().apiController.handlePost({ parameter: payload as Record, postData: { contents: '', @@ -259,9 +270,8 @@ function upsertClient( dailyQuota: number, note: string ): ApiResult { - return sheetRepository.withScriptLock(() => - sheetRepository.upsertClient(clientCode, ownerName, capabilityToken, expiresAtIso, dailyQuota, note) - ); + const { repository } = buildRuntimeContext_(); + return repository.withScriptLock(() => repository.upsertClient(clientCode, ownerName, capabilityToken, expiresAtIso, dailyQuota, note)); } function json_(obj: JsonObject | ApiResult): GoogleAppsScript.Content.TextOutput { diff --git a/gas/src/repository.ts b/gas/src/repository.ts index 9808304..5e4af15 100644 --- a/gas/src/repository.ts +++ b/gas/src/repository.ts @@ -185,6 +185,8 @@ class SheetRepository { const row = this.findClientRowByCode_(clientsSheet, clientCode); if (row === 0) return { success: false, result: '', error: 'client_not_found' }; + this.config.requirePublicSiteUrl(); + const capabilityToken = RandomUtil.randomToken(this.config.capabilityTokenLength); const capabilityTokenHash = DigestUtil.sha256Hex(capabilityToken); const nowIso = DateUtil.nowIso(); @@ -235,6 +237,8 @@ class SheetRepository { const tokenHint = `${capabilityToken.slice(0, 6)}...`; const note = InputNormalizer.text(noteInput); + this.config.requirePublicSiteUrl(); + const clientsSheet = this.ensureClientsSheet_(); clientsSheet.appendRow([ clientCode, diff --git a/package.json b/package.json index 0826de0..8e4d75f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:gas": "tsc --project tsconfig.gas.json && node scripts/build-gas.mjs", "typecheck:gas": "tsc --noEmit --project tsconfig.gas.json", "check:gas": "node --check gas/Code.js && test -f gas/appsscript.json", + "check:production-health": "node scripts/check-production-health.mjs", "gas:push": "node scripts/push-gas.mjs", "gas:push:local": "node scripts/push-gas.mjs --env local", "gas:push:production": "node scripts/push-gas.mjs --env production", diff --git a/public/CNAME b/public/CNAME deleted file mode 100644 index 7b7ab11..0000000 --- a/public/CNAME +++ /dev/null @@ -1 +0,0 @@ -t.purr.tw diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 3074bcd..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://t.purr.tw/sitemap-index.xml diff --git a/scripts/check-production-health.mjs b/scripts/check-production-health.mjs new file mode 100644 index 0000000..9a4bcbf --- /dev/null +++ b/scripts/check-production-health.mjs @@ -0,0 +1,296 @@ +import { appendFile } from 'node:fs/promises'; +import { normalizeSiteUrl } from './site-url.mjs'; + +const argv = process.argv.slice(2); + +const readOptionValue = (flag) => { + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + if (current === flag) { + return argv[index + 1]; + } + if (current.startsWith(`${flag}=`)) { + return current.slice(flag.length + 1); + } + } + return undefined; +}; + +const readText = (...values) => { + for (const value of values) { + const text = String(value || '').trim(); + if (text) return text; + } + return ''; +}; + +const readInt = (value, fallback) => { + const parsed = Number.parseInt(String(value || '').trim(), 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const normalizeComparableUrl = (value) => { + const url = new URL(value); + const pathname = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/u, ''); + return `${url.origin}${pathname}${url.search}`; +}; + +const fetchText = async (url) => { + const response = await fetch(url, { redirect: 'follow' }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status} while fetching ${url}: ${text.slice(0, 200)}`); + } + return text; +}; + +const extractScriptUrls = (html, frontendUrl) => { + const matches = [...html.matchAll(/]*\bsrc=(['"])(.*?)\1/giu)]; + const frontendOrigin = new URL(frontendUrl).origin; + const urls = new Set(); + + for (const match of matches) { + const rawUrl = String(match[2] || '').trim(); + if (!rawUrl) continue; + const absoluteUrl = new URL(rawUrl, frontendUrl); + if (absoluteUrl.origin !== frontendOrigin) continue; + if (!absoluteUrl.pathname.endsWith('.js')) continue; + urls.add(absoluteUrl.toString()); + } + + return [...urls]; +}; + +const extractApiUrls = (text) => { + const matches = [...text.matchAll(/https:\/\/script\.google\.com\/macros\/s\/[A-Za-z0-9_-]+\/exec/gu)]; + return [...new Set(matches.map((match) => match[0]))]; +}; + +const extractDeploymentId = (apiUrl) => { + const match = String(apiUrl || '').match(/\/macros\/s\/([A-Za-z0-9_-]+)\/exec/u); + return match ? match[1] : ''; +}; + +const collectLiveFrontendTarget = async (frontendUrl) => { + const html = await fetchText(frontendUrl); + const scriptUrls = extractScriptUrls(html, frontendUrl); + + if (scriptUrls.length === 0) { + throw new Error(`No JavaScript assets found in live frontend HTML: ${frontendUrl}`); + } + + const matchedBundles = []; + + for (const scriptUrl of scriptUrls) { + const bundleText = await fetchText(scriptUrl); + const apiUrls = extractApiUrls(bundleText); + if (apiUrls.length === 0) continue; + matchedBundles.push({ scriptUrl, apiUrls }); + } + + const distinctApiUrls = [...new Set(matchedBundles.flatMap((bundle) => bundle.apiUrls))]; + + if (distinctApiUrls.length === 0) { + throw new Error(`No Apps Script exec URL found in live frontend bundles for ${frontendUrl}`); + } + + if (distinctApiUrls.length > 1) { + throw new Error(`Multiple Apps Script exec URLs found in live frontend bundles: ${distinctApiUrls.join(', ')}`); + } + + return { + frontendUrl: normalizeSiteUrl(frontendUrl), + htmlScriptUrls: scriptUrls, + matchedBundleUrls: matchedBundles.map((bundle) => bundle.scriptUrl), + liveApiUrl: distinctApiUrls[0], + liveDeploymentId: extractDeploymentId(distinctApiUrls[0]) + }; +}; + +const fetchRuntimeStatus = async (apiUrl) => { + const url = new URL(apiUrl); + url.searchParams.set('action', 'runtime_config_status'); + url.searchParams.set('_healthcheck', String(Date.now())); + + const response = await fetch(url, { redirect: 'follow' }); + const text = await response.text(); + + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`Runtime status did not return JSON: ${text.slice(0, 200)}`); + } + + if (!response.ok) { + throw new Error(`Runtime status HTTP ${response.status}: ${text.slice(0, 200)}`); + } + + return json; +}; + +const writeStepSummary = async (status, summary, errors) => { + const filePath = process.env.GITHUB_STEP_SUMMARY; + if (!filePath) return; + + const lines = [ + '## Production Health Check', + '', + `- Status: ${status}` + ]; + + if (summary) { + lines.push(`- Frontend URL: ${summary.frontendUrl}`); + lines.push(`- Live API URL: ${summary.liveApiUrl || 'n/a'}`); + lines.push(`- Expected API URL: ${summary.expectedApiUrl || 'n/a'}`); + lines.push(`- Live Deployment ID: ${summary.liveDeploymentId || 'n/a'}`); + lines.push(`- Expected Deployment ID: ${summary.expectedDeploymentId || 'n/a'}`); + lines.push(`- Runtime Environment: ${String(summary.runtimeStatus?.environment || 'n/a')}`); + lines.push(`- Runtime Public Site URL: ${String(summary.runtimeStatus?.publicSiteUrl || 'n/a')}`); + if (Array.isArray(summary.matchedBundleUrls) && summary.matchedBundleUrls.length > 0) { + lines.push(`- Matched Bundle: ${summary.matchedBundleUrls.join(', ')}`); + } + } + + if (errors.length > 0) { + lines.push(''); + lines.push('### Errors'); + lines.push(''); + for (const error of errors) { + lines.push(`- ${error}`); + } + } + + lines.push(''); + await appendFile(filePath, `${lines.join('\n')}\n`, 'utf8'); +}; + +const createFailure = (message, extra = {}) => Object.assign(new Error(message), extra); + +const asRecord = (value) => { + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; +}; + +const frontendUrl = readText( + readOptionValue('--frontend-url'), + process.env.HEALTHCHECK_FRONTEND_URL, + process.env.GAS_PUBLIC_SITE_URL, + process.env.PUBLIC_SITE_URL +); +const expectedDeploymentId = readText( + readOptionValue('--expected-deployment-id'), + process.env.EXPECTED_GAS_DEPLOYMENT_ID, + process.env.GAS_DEPLOYMENT_ID +); +const expectedApiUrl = readText( + readOptionValue('--expected-api-url'), + process.env.EXPECTED_API_URL, + expectedDeploymentId ? `https://script.google.com/macros/s/${expectedDeploymentId}/exec` : '' +); +const expectedEnvironment = readText( + readOptionValue('--expected-environment'), + process.env.EXPECTED_RUNTIME_ENVIRONMENT, + 'production' +); +const expectedPublicSiteUrl = normalizeSiteUrl( + readText( + readOptionValue('--expected-public-site-url'), + process.env.EXPECTED_PUBLIC_SITE_URL, + process.env.GAS_PUBLIC_SITE_URL, + process.env.PUBLIC_SITE_URL, + frontendUrl + ) +); +const retries = readInt(readOptionValue('--retries') ?? process.env.HEALTHCHECK_RETRIES, 0); +const retryDelayMs = readInt(readOptionValue('--retry-delay-ms') ?? process.env.HEALTHCHECK_RETRY_DELAY_MS, 5000); + +if (!expectedApiUrl) { + throw new Error('Missing expected GAS API target. Provide --expected-deployment-id or --expected-api-url.'); +} + +if (!frontendUrl) { + throw new Error('Missing required frontend URL for production health check.'); +} + +const runOnce = async () => { + const liveTarget = await collectLiveFrontendTarget(frontendUrl); + const runtimeStatus = await fetchRuntimeStatus(liveTarget.liveApiUrl); + const runtimeStatusRecord = asRecord(runtimeStatus); + const summary = { + checkedAt: new Date().toISOString(), + frontendUrl: liveTarget.frontendUrl, + matchedBundleUrls: liveTarget.matchedBundleUrls, + liveApiUrl: liveTarget.liveApiUrl, + liveDeploymentId: liveTarget.liveDeploymentId, + expectedApiUrl, + expectedDeploymentId, + expectedEnvironment, + expectedPublicSiteUrl, + runtimeStatus + }; + const errors = []; + + if (normalizeComparableUrl(liveTarget.liveApiUrl) !== normalizeComparableUrl(expectedApiUrl)) { + errors.push(`live frontend points to unexpected GAS API: expected ${expectedApiUrl} but found ${liveTarget.liveApiUrl}`); + } + + if (expectedDeploymentId && liveTarget.liveDeploymentId !== expectedDeploymentId) { + errors.push( + `live frontend deployment id mismatch: expected ${expectedDeploymentId} but found ${liveTarget.liveDeploymentId}` + ); + } + + if (!runtimeStatusRecord || runtimeStatusRecord.success !== true) { + errors.push(`runtime_config_status returned unexpected payload: ${JSON.stringify(runtimeStatus)}`); + } + + if (runtimeStatusRecord) { + if (String(runtimeStatusRecord.environment || '') !== expectedEnvironment) { + errors.push( + `runtime environment mismatch: expected ${expectedEnvironment} but found ${String(runtimeStatusRecord.environment || '')}` + ); + } + + if (normalizeSiteUrl(readText(runtimeStatusRecord.publicSiteUrl)) !== expectedPublicSiteUrl) { + errors.push( + `runtime public site url mismatch: expected ${expectedPublicSiteUrl} but found ${String(runtimeStatusRecord.publicSiteUrl || '')}` + ); + } + } + + if (errors.length > 0) { + throw createFailure(errors[0], { summary, validationErrors: errors }); + } + + return summary; +}; + +let lastFailure = null; +const totalAttempts = retries + 1; + +for (let attempt = 1; attempt <= totalAttempts; attempt += 1) { + try { + const summary = await runOnce(); + console.log(JSON.stringify({ success: true, attempt, summary }, null, 2)); + await writeStepSummary('success', summary, []); + process.exit(0); + } catch (error) { + lastFailure = error; + console.error(`[production-health] attempt ${attempt}/${totalAttempts} failed: ${error.message}`); + if (attempt < totalAttempts) { + await sleep(retryDelayMs); + } + } +} + +const failureErrors = Array.isArray(lastFailure?.validationErrors) + ? lastFailure.validationErrors + : [lastFailure?.message || 'unknown production health check failure']; +const failureSummary = lastFailure?.summary || null; + +console.log(JSON.stringify({ success: false, errors: failureErrors, summary: failureSummary }, null, 2)); +await writeStepSummary('failure', failureSummary, failureErrors); +throw createFailure(failureErrors[0]); \ No newline at end of file diff --git a/scripts/run-frontend.mjs b/scripts/run-frontend.mjs index 36ae066..3734fc1 100644 --- a/scripts/run-frontend.mjs +++ b/scripts/run-frontend.mjs @@ -1,5 +1,7 @@ import { spawnSync } from 'node:child_process'; +import path from 'node:path'; import { loadAppEnv, omitOption, requireEnvText, resolveAppEnv } from './env-utils.mjs'; +import { requirePublicSiteUrl, writeFrontendSiteArtifacts } from './site-url.mjs'; const rootDir = process.cwd(); const argv = process.argv.slice(2); @@ -14,6 +16,7 @@ const commandArgs = argv.slice(1); const defaultEnv = astroCommand === 'dev' ? 'local' : 'production'; const appEnv = resolveAppEnv(commandArgs, { defaultEnv }); const env = loadAppEnv(rootDir, appEnv); +const publicSiteUrl = requirePublicSiteUrl(env, 'Missing required frontend site URL env'); requireEnvText(env, 'PUBLIC_API_URL', 'Missing required frontend env'); requireEnvText(env, 'PUBLIC_TURNSTILE_SITE_KEY', 'Missing required frontend env'); @@ -23,6 +26,7 @@ const commandName = (base) => (process.platform === 'win32' ? `${base}.cmd` : ba const childEnv = { ...process.env, ...env, + PUBLIC_SITE_URL: publicSiteUrl, SHORTYOU_ENV: appEnv }; @@ -34,4 +38,8 @@ const result = spawnSync(commandName('astro'), [astroCommand, ...astroArgs], { if (result.status !== 0) { throw new Error(`Frontend command failed: astro ${astroCommand}`); +} + +if (astroCommand === 'build') { + await writeFrontendSiteArtifacts(path.join(rootDir, 'dist'), publicSiteUrl); } \ No newline at end of file diff --git a/scripts/site-url.mjs b/scripts/site-url.mjs new file mode 100644 index 0000000..0769d43 --- /dev/null +++ b/scripts/site-url.mjs @@ -0,0 +1,51 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const readText = (...values) => { + for (const value of values) { + const text = String(value || '').trim(); + if (text) return text; + } + return ''; +}; + +export const normalizeSiteUrl = (value) => { + const text = readText(value); + if (!text) return ''; + + const url = new URL(text); + const pathname = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/u, ''); + return `${url.origin}${pathname}`; +}; + +export const resolvePublicSiteUrl = (env = process.env) => { + return normalizeSiteUrl( + readText(env.PUBLIC_SITE_URL, env.GAS_PUBLIC_SITE_URL, env.HEALTHCHECK_FRONTEND_URL) + ); +}; + +export const requirePublicSiteUrl = (env = process.env, prefix = 'Missing required public site URL env') => { + const siteUrl = resolvePublicSiteUrl(env); + if (!siteUrl) { + throw new Error(`${prefix}: PUBLIC_SITE_URL or GAS_PUBLIC_SITE_URL`); + } + return siteUrl; +}; + +export const buildCnameText = (siteUrl) => `${new URL(normalizeSiteUrl(siteUrl)).hostname}\n`; + +export const buildRobotsTxt = (siteUrl) => { + normalizeSiteUrl(siteUrl); + return 'User-agent: *\nAllow: /\n'; +}; + +export const writeFrontendSiteArtifacts = async (outDir, siteUrl) => { + const normalizedSiteUrl = normalizeSiteUrl(siteUrl); + if (!normalizedSiteUrl) { + throw new Error('Missing required public site URL when generating frontend site artifacts.'); + } + + await mkdir(outDir, { recursive: true }); + await writeFile(path.join(outDir, 'CNAME'), buildCnameText(normalizedSiteUrl), 'utf8'); + await writeFile(path.join(outDir, 'robots.txt'), buildRobotsTxt(normalizedSiteUrl), 'utf8'); +}; \ No newline at end of file diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 63c1cf7..cff17fb 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -14,7 +14,7 @@ const { title, description, robots, - canonical = 'https://t.purr.tw/' + canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site).toString() : '' } = Astro.props as Props; --- @@ -26,7 +26,7 @@ const { {title} - + {canonical && }