diff --git a/content-editor.js b/content-editor.js index 7c74ed8..d9c0c1c 100644 --- a/content-editor.js +++ b/content-editor.js @@ -11,7 +11,7 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); -const { exec } = require('child_process'); +const { exec, execSync } = require('child_process'); const crypto = require('crypto'); const { validateContent } = require('./proto/src/content/schema'); const { renderMarkdown } = require('./scripts/markdown-render'); @@ -133,6 +133,17 @@ function backupAndWriteAtomically(targetFile, data, backupDir = BACKUP_DIR) { return backupFile; } +// Deploy version = git short SHA of the checked-out commit. Read once at startup: +// a CMS "deploy" is git pull + service restart, so this identifies the running code. +function readDeployVersion() { + try { + return execSync('git rev-parse --short HEAD', { cwd: __dirname, encoding: 'utf8' }).trim(); + } catch { + return 'unknown'; + } +} +const DEPLOY_VERSION = readDeployVersion(); + const server = http.createServer(async (req, res) => { const clientAddress = getClientAddress(req); if (!securityConfigIsValid()) { @@ -167,6 +178,13 @@ const server = http.createServer(async (req, res) => { return; } + // Public: deploy version (git SHA only — no secrets) for quick "is the fix live?" checks. + if (req.method === 'GET' && u.pathname === '/version') { + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV })); + return; + } + // Public: styled login page (shown after logout and for unauthenticated browser visits). if (req.method === 'GET' && u.pathname === '/login') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); @@ -366,7 +384,7 @@ const server = http.createServer(async (req, res) => { const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash)); + res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash, DEPLOY_VERSION)); }); if (require.main === module) { @@ -374,7 +392,8 @@ if (require.main === module) { throw new Error('CMS_USER, CMS_PASS és érvényes CMS_DEPLOY_ENV nélkül a Content Editor nem indítható el.'); } server.listen(PORT, '127.0.0.1', () => { - console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT}\n`); + writeAudit('startup', { version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV }); + console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT} (v${DEPLOY_VERSION})\n`); console.log(' Szerkeszthető fájlok:'); Object.entries(FILE_LABELS).forEach(([k, l]) => { const rel = k === 'common' ? 'common.json' : `pages/${k}.json`; diff --git a/deploy.sh b/deploy.sh index d64a2d8..87ce062 100755 --- a/deploy.sh +++ b/deploy.sh @@ -28,6 +28,10 @@ echo "🚀 Deploy indítása: [$ENV] környezet (${COMPOSE_FILE})" echo "📦 Kód frissítése a main ágról..." git pull origin main +# Deploy version = git short SHA; baked into the image and served via /api/health +# so "which build is live?" is a single curl away. +export DEPLOY_VERSION="$(git rev-parse --short HEAD)" + # 2. Környezeti változók (.env.) ENV_FILE=".env.${ENV}" if [ ! -f "$ENV_FILE" ]; then diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d8a1832..066c4c5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,6 +7,7 @@ services: target: runner # Use runner stage for production (smaller size, no dev dependencies) args: - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://mozdit.hu} + - DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned} container_name: mozdit-app-prod ports: - "8080:3000" # Host port 8080 elkerüli a lokális npm dev (3000) összeakadást diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 77c9f52..1535fb5 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -8,6 +8,7 @@ services: args: - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://stage.mozdit.hu} - NEXT_PUBLIC_DEPLOY_ENV=staging + - DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned} container_name: mozdit-app-staging ports: - "127.0.0.1:8081:3000" # Belső port — csak nginx-en keresztül elérhető diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index a234c83..09cfce7 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -45,7 +45,7 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció - **Fájl fülek** (felül): oldalankénti tartalom — Kezdőlap, Rólunk, Szolgáltatások, Kapcsolat, jogi oldalak, közös szövegek. - **Szerkesztőfelület**: a kiválasztott oldal összes szerkeszthető mezője. -- **Alsó sáv**: 💾 Mentés, 🚀 Publikálás, 🔗 Előnézet, ❓ Súgó, 🚪 Kilépés. +- **Alsó sáv**: 💾 Mentés, 🚀 Publikálás, 🔗 Előnézet, ❓ Súgó, 🚪 Kilépés, valamint a **futó verzió** (pl. `va7b1a2c`) — ha a fejlesztő megkér, hogy ellenőrizd a verziót, ezt a jelölést mondd neki. ### Szöveg szerkesztése diff --git a/proto/Dockerfile b/proto/Dockerfile index 07c88ef..c65192f 100755 --- a/proto/Dockerfile +++ b/proto/Dockerfile @@ -10,6 +10,10 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL ARG NEXT_PUBLIC_DEPLOY_ENV ENV NEXT_PUBLIC_DEPLOY_ENV=$NEXT_PUBLIC_DEPLOY_ENV +# Runtime-only deploy version (git SHA passed by deploy.sh) — exposed via /api/health. +ARG DEPLOY_VERSION +ENV DEPLOY_VERSION=$DEPLOY_VERSION + # Copy package files COPY package.json package-lock.json* ./ diff --git a/proto/e2e/staging-smoke.spec.ts b/proto/e2e/staging-smoke.spec.ts index ba233c9..5e6f57e 100644 --- a/proto/e2e/staging-smoke.spec.ts +++ b/proto/e2e/staging-smoke.spec.ts @@ -4,7 +4,11 @@ test('SMOKE-01: health endpoint is available', async ({ request }) => { const response = await request.get('/api/health') expect(response.status()).toBe(200) await expect(response).toBeOK() + const body = await response.json() await expect(response.json()).resolves.toMatchObject({ status: 'ok' }) + // The deployed build must carry a git SHA stamp (deploy.sh injects it). + expect(typeof body.deployVersion).toBe('string') + expect(body.deployVersion).not.toBe('unversioned') }) test('SMOKE-02: homepage renders its critical shell without console errors', async ({ page }) => { diff --git a/proto/src/app/api/health/route.ts b/proto/src/app/api/health/route.ts index e979155..a3d7080 100755 --- a/proto/src/app/api/health/route.ts +++ b/proto/src/app/api/health/route.ts @@ -8,6 +8,9 @@ export async function GET() { timestamp: new Date().toISOString(), uptime: process.uptime(), version: process.env.npm_package_version || '1.0.0', + // Git SHA injected at build time by deploy.sh (Dockerfile ARG) — identifies + // the running build; null in local dev where no deploy stamp exists. + deployVersion: process.env.DEPLOY_VERSION || null, environment: process.env.NODE_ENV || 'development', }; diff --git a/scripts/cms-pages.js b/scripts/cms-pages.js index 7c06642..e351e13 100644 --- a/scripts/cms-pages.js +++ b/scripts/cms-pages.js @@ -4,7 +4,7 @@ const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging'; // FILE_LABELS is injected to avoid a circular dependency with the main file. -const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash) => ` +const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash, deployVersion) => ` @@ -68,6 +68,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; } .btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; } .btn-logout:hover { background: #374151; } + .version-tag { color: #475569; font-size: 12px; font-family: monospace; } .btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; } .btn-save:hover { opacity: .9; transform: translateY(-1px); } .btn-save:active { transform: translateY(0); } @@ -112,6 +113,7 @@ ${message ? `
${messag 🔗 Előnézet → ❓ Súgó + v${deployVersion}
diff --git a/scripts/test-content-editor-version.js b/scripts/test-content-editor-version.js new file mode 100644 index 0000000..108cb44 --- /dev/null +++ b/scripts/test-content-editor-version.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +/** + * Integration test for CMS deploy versioning (MITHOME-63): + * 1. GET /version is public and reports the git SHA of the checked-out commit + * 2. the editor page displays the same version in the bottom bar + * 3. a startup audit entry records the version + */ +const assert = require('assert/strict'); +const { execFileSync, spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const PORT = 4129; +const BASE = `http://127.0.0.1:${PORT}`; +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-version-')); +const auditFile = path.join(tmp, 'audit.jsonl'); + +const expectedVersion = execFileSync('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim(); + +const child = spawn('node', ['content-editor.js'], { + cwd: ROOT, + env: { + ...process.env, + CONTENT_EDITOR_PORT: String(PORT), + CONTENT_EDITOR_AUDIT_FILE: auditFile, + CMS_USER: 'version-test-user', + CMS_PASS: 'version-test-pass', + CMS_DEPLOY_ENV: 'staging', + }, + stdio: 'ignore', +}); + +async function waitForServer(timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fetch(`${BASE}/version`); + return; + } catch { + await new Promise(r => setTimeout(r, 200)); + } + } + throw new Error('server did not start'); +} + +async function main() { + await waitForServer(); + + // 1. public /version + const res = await fetch(`${BASE}/version`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.version, expectedVersion); + assert.equal(body.env, 'staging'); + + // 2. footer shows the same version + const login = await fetch(`${BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: 'version-test-user', pass: 'version-test-pass' }), + }); + const cookie = (login.headers.get('set-cookie') || '').split(';')[0]; + const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie, Accept: 'text/html' } })).text(); + assert.ok(page.includes(`v${expectedVersion}`), 'bottom bar must show the deploy version'); + + // 3. startup audit entry + const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l)); + const startup = audit.find(e => e.event === 'startup'); + assert.ok(startup, 'startup audit entry exists'); + assert.equal(startup.version, expectedVersion); + + console.log('Content Editor deploy version test: OK'); +} + +main() + .catch(err => { console.error('❌', err.message); process.exitCode = 1; }) + .finally(() => { + child.kill('SIGTERM'); + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } + });