chore: retire legacy custom CMS (content-editor.js) (MITHOME-93)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s

Removes the standalone, git-push-based content editor that predates
Payload CMS: content-editor.js, its scripts/cms-*.js modules, its
scripts/test-content-editor-*.js + scripts/test-cms-publish.js test
suite, scripts/markdown-render.js (only used by the editor's guide
renderer), the proto-side test doubles (cms-editor-client.test.ts,
cms-editor-shortcuts.test.ts), and the editor's own user guide
(docs/felhasznaloi-utmutato.md).

Kept: proto/src/content/*.json (still the source for
migrate-content-to-payload.ts and test fixtures for Header/Footer,
per MITHOME-96), proto/src/content/schema.js + scripts/test-content-schema.js
(still validate those JSON files), and docs/content-editor-recovery.md
(historical incident record, not user-facing tool docs).

Safety net before deletion (per user request): added
proto/scripts/export-content-snapshot.ts, a reusable Payload Local API
exporter, and ran it to produce docs/backups/payload-content-snapshot-*.json
— a full hu/en snapshot of every Global + LegalPages + Partners document
at the moment of retirement. Also confirmed no data-loss risk otherwise:
.content-backups/ (the editor's own gitignored backup dir) tops out at
2026-08-23, well before today's fresh migration run, and every JSON
edit ever made through the editor already exists as its own git commit
("content: frissítve a CMS-ből").

Updated dangling references: pre-deploy-tests.sh and
.agent/steering/testing.md (dropped the CMS test block),
.agent/workflows/deploy.md (publish flow is now Payload draft/publish,
not git push), CLAUDE.md + .agent/AGENTS.md (dropped the /cms-feature
workflow, deleted alongside it), README.md (stack description),
.agent/steering/development-rules.md (the guide-maintenance rule no
longer has a guide to maintain).

Verified: tsc, lint, proto unit tests (51 passed), root
test-content-schema.js, plane-sync unit tests, production build all
green after the deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Do Siki
2026-09-11 21:48:50 +02:00
co-authored by Claude Sonnet 5
parent 21a0d73639
commit 12b2711168
40 changed files with 1316 additions and 4199 deletions
-1
View File
@@ -279,7 +279,6 @@ Az `.agent/workflows/` mappában találhatók az elérhető slash command workfl
| --- | --- |
| `/new-feature` | Új funkció fejlesztési folyamata |
| `/fix-bug` | Hibajavítás folyamata |
| `/cms-feature` | CMS (Content Editor) fejlesztési folyamata |
| `/review` | Kód review checklist |
| `/deploy` | Deployment folyamata |
+2 -3
View File
@@ -76,9 +76,8 @@ git commit -m "fix(<scope>): <mi volt a hiba és hogyan lett javítva>"
## Felhasználói dokumentáció karbantartása
- A CMS **❓ Súgó** menüpontja a `docs/felhasznaloi-utmutato.md` fájlt rendereli (`/guide`).
- **Kötelező**: minden CMS- vagy honlapfunkció változtatásánál (új gomb, viselkedésváltozás, útvonal) ugyanabban a commitban frissítsd az útmutatót.
- Támogatott markdown forma a `scripts/markdown-render.js` részhalmaza: címsorok, **félkövér**, `kód`, listák, linkek, `---` elválasztók (táblázat nem).
- A régi egyedi CMS-nek (`content-editor.js`, saját **❓ Súgó** menüponttal, `docs/felhasznaloi-utmutato.md`) volt saját, karbantartott felhasználói útmutatója — ezt a Payload CMS-re állás után leépítettük (MITHOME-93). A Payload admin felület (`/admin`) saját, upstream dokumentációval rendelkezik; ügyfél-facing Payload-specifikus útmutató készítése külön feladat (MITHOME-95), ha szükséges.
- **Kötelező**: minden honlapfunkció-változtatásnál (új útvonal, viselkedésváltozás) ellenőrizd, hogy a `docs/` alatti releváns dokumentáció ne legyen elavult.
## Nyelvhasználat
+7 -17
View File
@@ -77,22 +77,12 @@ Minden deploy előtt az **egyetlen belépési pont** futtatandó:
scripts/pre-deploy-tests.sh
```
Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció, a CMS
integrációs tesztjei (`scripts/test-content-editor-*.js`), a publish-teszt és a
Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció és a
Plane-sync unit tesztek. Bármelyik hibája megszakítja a kiadást.
A CMS (content-editor.js) tesztjei külön is futtathatók (valódi szervert indítanak):
```bash
node scripts/test-content-editor-security.js # auth, CSRF, XFF
node scripts/test-content-editor-serializer.js # collect/reindex regresszió
node scripts/test-content-editor-save.js # atomikus mentés + validáció
node scripts/test-content-editor-conflict.js # optimista zárolás (409)
node scripts/test-content-editor-versions.js # verziók panel (diff, restore)
node scripts/test-content-editor-logo.js # logó feltöltés
node scripts/test-content-editor-login.js # login flow (session, Safari)
node scripts/test-content-editor-logout.js # logout + rate-limit
node scripts/test-content-editor-guide.js # /guide + markdown renderer
node scripts/test-content-editor-bottombar.js # alsó sáv layout guard
node scripts/test-cms-publish.js # publish parancs + integráció
```
> A régi egyedi CMS (content-editor.js) saját teszt-szkriptjeit (auth, CSRF,
> serializer, optimista zárolás, verziók panel, logó feltöltés, login/logout,
> guide, publish) a Payload CMS-re állás után (MITHOME-93) eltávolítottuk —
> a Payload admin felület a saját upstream tesztelésével fedett, ezt itt nem
> duplikáljuk. Payload collection/global konfigurációk saját tesztlefedettsége
> külön feladat (MITHOME-96), még nincs implementálva.
-86
View File
@@ -1,86 +0,0 @@
---
description: CMS (Content Editor) fejlesztési munkafolyamata — módosítástól a staging élesítésig
---
# CMS Fejlesztési Workflow (/cms-feature)
A `content-editor.js`, a hozzá tartozó scriptek (`scripts/markdown-render.js`, `scripts/test-content-editor-*.js`) és a CMS-en keresztül szerkeszthető tartalmi sémák (`proto/src/content/schema.js`) módosításakor kövesd ezt a folyamatot.
A szabály (a *miért*): `.agent/steering/development-rules.md` → „Felhasználói dokumentáció karbantartása". Ez a fájl a *hogyan*-t írja le.
---
## 1. Tervezés
- Fogalmazd meg, mi változik a felhasználó szemszögéből (új gomb? más viselkedés? új hibaüzenet?)
- **Új endpoint vagy UI elem esetén**: tervezd meg az útmutató érintett szakaszának frissítését is.
- Content séma (`schema.js`) változásnál ellenőrizd, hogy a meglévő JSON fájlok mind átmegyek az új sémán.
## 2. Implementáció
- A CMS system Nodeon fut, **nincs node_modules** — csak beépítő modulokat használj (`http`, `fs`, `path`, `crypto`, `child_process`).
- Fájlméret: a `content-editor.js` közelít a 300 soros soft limit felé — nagyobb funkciót szervezz ki külön modulba (pl. `scripts/markdown-render.js` minta).
- Biztonsági követelmények:
- Minden új POST endpoint **CSRF token ellenőrzéssel**.
- Új GET endpoint **auth után** serviced (kivétel: `/logout` minta).
- User input escape-elés (lásd `escapeHtml` a markdown rendererben).
- Titok soha nem kerül kimenetre — válaszkóddal (401/200) igazolj.
## 3. Útmutató frissítése (KÖTELEZŐ, ugyanabban a commitban)
- Forrás: `docs/felhasznaloi-utmutato.md` — a felhasználó nyelvén, nem műszaki részletekkel.
- Új UI elem → felület áttekintés szakasz + adott funkció szakasz frissítése.
- Támogatott markdown részhalmaz: címsorok, **félkövér**, `kód`, listák, linkek, `---` (táblázat nem — a `scripts/markdown-render.js` nem jeleníti meg).
## 4. Tesztek
Minden CMS-változtatás után futtasd mindet:
```bash
node --check content-editor.js
node scripts/test-content-editor-security.js # auth, CSRF, XFF
node scripts/test-content-editor-serializer.js # collect/reindex regresszió
node scripts/test-content-editor-save.js # atomic save + validáció
node scripts/test-content-editor-logout.js # /logout viselkedés
node scripts/test-content-editor-guide.js # /guide + Súgó link
```
- Új funkcióhoz **új tesztfájl** a fenti minta szerint (valós szervert indító integration teszt ajánlott).
- Content séma változásnál: `node scripts/test-content-schema.js` is.
## 5. Commit
```bash
git add content-editor.js scripts/ docs/felhasznaloi-utmutato.md
git commit -m "feat(cms): <rövid leírás>" # vagy fix(cms):
```
- Az útmutató frissítése **ugyanabban a commitban** landol, mint a funkció.
- Plane ticket (`Closes MITHOME-XX`) + `node plane-sync.js --yes` a TODO.md szinkronhoz.
## 6. Staging élesítés és ellenőrzés
A CMS a websitetől **független szolgáltatás** — deploy szkript nélkül, közvetlenül élesítjük:
```bash
ssh sadmin@llmdev.mozdit.hu '
cd /home/sadmin/websitedev && git pull --ff-only origin main &&
sudo systemctl restart mozdit-content-editor.service &&
systemctl is-active mozdit-content-editor.service'
```
Ellenőrzés (a hitelesítő adatokat az `/etc/mozdit-content-editor.env`-ből olvasd, **soha ne írd ki**):
```bash
# kulcs nélkül 401-et várunk
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4001/
# hitelesített kérést sudo sh -c ". /etc/mozdit-content-editor.env; curl -u ..." mintával futtass
```
- Az érintett funkciókra vonatkozó válaszkódok ellenőrzése (401/200/429 stb.).
- Ha a honlap (`proto/`) is változott: `./scripts/deploy_to_stage_on_local.sh` a teljes deployhoz.
## 7. Bezárás
- Plane ticket → Done.
- `node plane-sync.js --yes` + `git commit -m "sync: ..."` + push.
+2 -2
View File
@@ -45,9 +45,9 @@ Bármely lépés hibája azonnal leállítja a folyamatot. Paraméterek környez
2. `docker compose -f docker-compose.<env>.yml up --build --force-recreate -d` — a friss Dockerfile/build-arg változások is érvényesüljenek.
3. Healthcheck: max 60s-ig kérdezi a `http://localhost:<port>/api/health`-t (staging: 8081, prod: 8080). Sikertelenség esetén app-logok kiírása + exit 1.
## CMS-ből történő publikálás
## Tartalom publikálása (Payload admin)
A Content Editor (`content-editor.js`) Publikálás gombja: commit + push (`content: frissítve a CMS-ből`), majd a `CMS_DEPLOY_ENV`-ben beállított környezet deployja a háttérben. **Soha nem deployol productionre implicit** — a `CMS_DEPLOY_ENV` kötelező, érvényes érték nélkül az editor el sem indul.
A régi Content Editor (`content-editor.js`, git push-alapú publikálás) helyét a Payload admin (`/admin`) draft/publish + verziózás funkciója vette át (MITHOME-92/93) — ez nem jár git commit-tal vagy automatikus deployjal, a tartalom közvetlenül a MongoDB-be íródik. Kódváltozás (Globals/Collections séma, frontend) továbbra is a lenti git-alapú deploy folyamaton megy át.
## Production deploy (élesítés)
+1 -2
View File
@@ -59,7 +59,7 @@ websitedev/
├── .agent/ # AI szabályrendszer ← OLVASD EL
│ ├── AGENTS.md # Elsődleges szabályok
│ ├── steering/ # Auto-betöltődő irányelvek
│ ├── workflows/ # /new-feature, /fix-bug, /cms-feature, /review, /deploy
│ ├── workflows/ # /new-feature, /fix-bug, /review, /deploy
│ └── references/ # Accessibility checklist
├── TODO.md # Feladatlista (Plane szinkron)
└── plane-sync.js # Plane szinkronizáló script
@@ -98,7 +98,6 @@ Szükséges változók (`.env` és `proto/.env.local`):
| `.agent/steering/testing.md` | Tesztelési stratégia, coverage elvárások |
| `.agent/workflows/new-feature.md` | Új funkció fejlesztési lépései |
| `.agent/workflows/fix-bug.md` | Hibajavítás lépései (TDD) |
| `.agent/workflows/cms-feature.md` | CMS fejlesztés + útmutató karbantartás |
| `.agent/workflows/review.md` | Code review checklist |
| `.agent/workflows/deploy.md` | Deployment folyamat |
| `.agent/references/accessibility-checklist.md` | WCAG 2.1 AA ellenőrzőlista |
+3 -3
View File
@@ -5,10 +5,10 @@ Modern Next.js weboldal a mozdIT Bt. számára — webtárhely, email- és DNS-s
## Stack
- **Frontend**: Next.js 15 (Turbopack), React 19, TypeScript, Tailwind CSS 4
- **Tartalom**: JSON-alapú, séma-validált content rendszer (`proto/src/content/`)
- **CMS**: saját, dependency-mentes `content-editor.js` (böngészős szerkesztő)
- **CMS**: Payload CMS (self-hosted, `/admin`), MongoDB adapter, draft/publish + verziózás, hu/en lokalizáció
- **Tartalom**: Payload Globals/Collections (a korábbi JSON-alapú content rendszer, `proto/src/content/`, csak a migrációs script forrásaként és teszt-fixture-ként él tovább — MITHOME-91/93)
- **Backend**: Next.js API routes, MongoDB
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke), valódi szervert indító CMS-tesztek
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke)
- **Deploy**: natív Docker Compose (`deploy.sh`) + Gitea Actions nélkül, lokálisan vezérelt
- **Monitoring**: Winston + Loki, plusz `scripts/security-scan.sh` (ntfy riasztással)
-362
View File
@@ -1,362 +0,0 @@
#!/usr/bin/env node
/**
* mozdIT Content Editor Server v2
* Szerkesztő felület a JSON tartalom fájlokhoz
* Támogatja: szöveg szerkesztés, tömbelem hozzáadás/törlés
* Futtatás: node content-editor.js
* Megnyitás: http://localhost:4001
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { exec, execSync } = require('child_process');
const crypto = require('crypto');
const { validateContent } = require('./proto/src/content/schema');
const { renderMarkdown } = require('./scripts/markdown-render');
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
const { handleSaveRoute } = require('./scripts/cms-save');
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
// WHY: overridable so the publish integration test can run against a throwaway
// git clone instead of the real repository.
const CONTENT_DIR = process.env.CONTENT_EDITOR_CONTENT_DIR || path.join(__dirname, 'proto', 'src', 'content');
const BACKUP_DIR = path.join(__dirname, '.content-backups');
const MAX_REQUEST_BODY_BYTES = 256 * 1024;
const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl');
const GUIDE_FILE = process.env.CONTENT_EDITOR_GUIDE_FILE || path.join(__dirname, 'docs', 'felhasznaloi-utmutato.md');
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const AUTH_MAX_ATTEMPTS = 5;
const PUBLISH_MAX_ATTEMPTS = 3;
let isPublishing = false;
const FILES = {
common: path.join(CONTENT_DIR, 'common.json'),
home: path.join(CONTENT_DIR, 'pages', 'home.json'),
about: path.join(CONTENT_DIR, 'pages', 'about.json'),
services: path.join(CONTENT_DIR, 'pages', 'services.json'),
contact: path.join(CONTENT_DIR, 'pages', 'contact.json'),
adatvedelem: path.join(CONTENT_DIR, 'pages', 'adatvedelem.json'),
hasznalatiFeltetelek: path.join(CONTENT_DIR, 'pages', 'hasznalati-feltetelek.json'),
};
const FILE_LABELS = {
common: '⚙️ Közös szövegek',
home: '🏠 Kezdőlap',
about: '👥 Rólunk',
services: '🛠️ Szolgáltatások',
contact: '📬 Kapcsolat',
adatvedelem: '🔒 Adatvédelem',
hasznalatiFeltetelek: '⚖️ ÁSZF',
};
const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages');
const { LOGO_PAGE } = require('./scripts/cms-logo-page');
const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session');
// Browser script is kept in its own file and inlined into the HTML template at render time.
const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-shortcuts.js'), 'utf8');
// ── Server ───────────────────────────────────────────────────────────────────
// Security/infra helpers live in scripts/cms-core.js (file-size limits).
const core = require('./scripts/cms-core');
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core;
const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS);
const isRateLimited = (key, limit) => core.isRateLimited(key, limit, RATE_LIMIT_WINDOW_MS);
const recordRateLimitAttempt = key => core.recordRateLimitAttempt(key, RATE_LIMIT_WINDOW_MS);
const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
const isBrowserNavigation = core.isBrowserNavigation;
const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE);
// 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) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
const clientAddress = getClientAddress(req);
if (!securityConfigIsValid()) {
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Content Editor is disabled: CMS_USER and CMS_PASS must be configured.');
return;
}
const u = new URL(req.url, `http://localhost:${PORT}`);
// WHY: Basic Auth credentials are cached by the browser until it closes, so there is
// no native logout. The client calls /logout with deliberately invalid credentials,
// which overwrites the cached pair; the next navigation prompts for login again.
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
if (u.pathname === '/logout' && req.method === 'GET') {
// Legacy cache-buster endpoint; no WWW-Authenticate — Safari would show its
// native auth dialog on any fetch hitting this challenge.
res.writeHead(401, { 'Cache-Control': 'no-store' });
res.end('Logged out');
return;
}
// Public: logo asset for the login page.
if (req.method === 'GET' && u.pathname === '/logo.png') {
try {
// ?variant=header serves the website header logo (branding page preview).
const file = u.searchParams.get('variant') === 'header' ? LOGO_TARGETS.header : LOGO_TARGETS.icon;
const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', file));
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
res.end(logo);
} catch {
res.writeHead(404); res.end('Not found');
}
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' });
res.end(LOGIN_PAGE());
return;
}
// Public: login form endpoint. Shares the auth rate-limit budget with failed
// Basic attempts so the form cannot be brute-forced either.
if (req.method === 'POST' && u.pathname === '/login') {
let body = '';
let bodyTooLarge = false;
req.on('data', c => {
if (body.length + c.length > 1024) { bodyTooLarge = true; return; }
body += c;
});
req.on('end', () => {
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
let user = '';
let pass = '';
try {
const parsed = JSON.parse(body);
user = String(parsed.user || '');
pass = String(parsed.pass || '');
} catch { /* empty credentials fail validation below */ }
if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) {
// WHY: successful logins must not consume the failure budget — tests and
// multi-tab users log in repeatedly and would lock themselves out.
const isSecure = req.headers['x-forwarded-proto'] === 'https';
writeAudit('login_success', { clientAddress });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) });
res.end(JSON.stringify({ ok: true }));
return;
}
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
});
return;
}
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('authentication_failed', { clientAddress, limited: true });
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end('Too many authentication attempts');
return;
}
if (!isAuthenticated(req)) {
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('authentication_failed', { clientAddress, limited: false });
// Browser navigations land on the styled login page; API/curl gets a plain 401.
// WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls
// that receive a Basic challenge — the styled /login page handles browsers.
if (isBrowserNavigation(req)) {
res.writeHead(302, { Location: '/login', 'Cache-Control': 'no-store' });
res.end();
return;
}
res.writeHead(401, { 'Cache-Control': 'no-store' });
res.end('Access denied');
return;
}
const fileKey = u.searchParams.get('file') || 'home';
const activeFile = FILES[fileKey] ? fileKey : 'home';
if (req.method === 'POST' && !hasValidCsrfToken(req)) {
writeAudit('csrf_rejected', { clientAddress, path: u.pathname, file: activeFile });
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen vagy hiányzó CSRF token' }));
return;
}
// POST /logout — invalidate the browser session (Basic Auth stays valid by design).
if (req.method === 'POST' && u.pathname === '/logout') {
deleteSession(req);
writeAudit('logout', { clientAddress, user: CMS_USER });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() });
res.end(JSON.stringify({ ok: true }));
return;
}
// GET /guide — user guide rendered from the maintained markdown in the repo.
if (req.method === 'GET' && u.pathname === '/guide') {
let contentHtml;
try {
contentHtml = renderMarkdown(fs.readFileSync(GUIDE_FILE, 'utf8'));
} catch (error) {
contentHtml = '<p>Az útmutató jelenleg nem elérhető. Kérlek, szólj a fejlesztőnek.</p>';
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(GUIDE_PAGE(contentHtml));
return;
}
// POST /save — handled in scripts/cms-save.js (optimistic lock + validation + backup).
if (handleSaveRoute({
req, res, u, activeFile, files: FILES, maxBodyBytes: MAX_REQUEST_BODY_BYTES,
validate: validateContent, writeAudit, backupAndWrite: backupAndWriteAtomically,
backupDir: BACKUP_DIR, user: CMS_USER, clientAddress, cmsDirname: __dirname,
})) return;
// POST /publish — Git Commit, Pull Rebase & Push
if (req.method === 'POST' && u.pathname === '/publish') {
if (isPublishing) {
res.writeHead(423, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Már folyamatban van egy publikálás. Kérlek, várj.' }));
return;
}
isPublishing = true;
if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) {
isPublishing = false;
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok publikálási kísérlet' }));
return;
}
// Command shape and result classification live in scripts/cms-publish.js
// (WHY comments there): commit only when staged changes exist, rebase with
// abort-on-failure, deterministic no-changes marker instead of output matching.
exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
isPublishing = false;
res.writeHead(200, { 'Content-Type': 'application/json' });
const outcome = interpretPublishResult(error, stdout, stderr);
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });
if (!outcome.ok) {
res.end(JSON.stringify({ ok: false, error: outcome.error }));
return;
}
// Deploy only when content actually changed — a no-op publish must not
// trigger a rebuild. Deploy only the explicitly configured environment;
// never default to production. Overridable for tests.
if (outcome.hadChanges) {
// WHY direct child instead of a detached `cmd &`: under the systemd unit's
// hardening (NoNewPrivileges/PrivateTmp) the backgrounded grandchild died
// silently (observed twice: stale site after a publish). A direct child is
// not detached, runs to completion, and the callback turns the audit entry
// into a real "deploy finished/failed" signal. The HTTP response is already
// sent; deploy output goes to deploy.log so the pipes stay quiet.
const deployCmd = process.env.CONTENT_EDITOR_DEPLOY_CMD
|| `cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1`;
writeAudit('deploy_spawned', { clientAddress, user: CMS_USER, env: CMS_DEPLOY_ENV });
// WHY cwd: without it the child starts in the process working directory
// (repo root), where `cd ../../../` lands on "/" — no write access, so
// deploy.log creation failed with Permission denied and the deploy never
// ran. CONTENT_DIR is the same base the git publish command uses.
exec(deployCmd, { cwd: CONTENT_DIR, maxBuffer: 8 * 1024 * 1024 }, deployError => {
writeAudit('deploy_exec_exit', {
clientAddress,
user: CMS_USER,
result: deployError ? 'error' : 'ok',
error: deployError ? String(deployError.message).slice(0, 300) : undefined,
});
});
}
res.end(JSON.stringify({ ok: true, output: outcome.output }));
});
return;
}
// GET /versions + POST /restore — handled in scripts/cms-versions.js.
if (handleVersionRoutes({
req, res, u, activeFile,
backupDir: BACKUP_DIR,
currentFile: FILES[activeFile],
validate: validateContent,
writeAudit, clientAddress, user: CMS_USER,
versionsPage: (fileKey, diff) => VERSIONS_PAGE(fileKey, FILE_LABELS[fileKey] || fileKey, listVersions(BACKUP_DIR, fileKey), diff, CSRF_TOKEN),
})) return;
// GET /branding + POST /logo — handled in scripts/cms-logo.js.
if (handleLogoRoutes({
req, res, u,
publicDir: path.join(__dirname, 'proto', 'public'),
backupDir: BACKUP_DIR,
writeAudit, clientAddress, user: CMS_USER,
logoPage: () => LOGO_PAGE(CSRF_TOKEN),
})) return;
// GET / — editor UI
let message = null;
let jsonData = '{}';
try {
jsonData = fs.readFileSync(FILES[activeFile], 'utf8').trim();
} catch (e) {
message = { type: 'err', text: 'Fájl olvasási hiba: ' + e.message };
}
// WHY: fingerprint of the file content at page load. The editor sends it back
// on save (X-Content-Hash); a mismatch means the file changed since this tab
// was opened (deploy, another tab, git) and a blind save would silently
// overwrite those changes.
const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash, DEPLOY_VERSION));
});
if (require.main === module) {
if (!securityConfigIsValid()) {
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', () => {
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`;
console.log(`${l}: proto/src/content/${rel}`);
});
console.log('\n Ctrl+C a leállításhoz\n');
});
}
module.exports = {
backupAndWriteAtomically,
validateContent,
hasValidCredentials,
hasValidCsrfToken,
getClientAddress,
securityConfigIsValid,
csrfToken: CSRF_TOKEN,
};
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
# mozdIT — Felhasználói útmutató
Ez az útmutató a mozdIT weboldalt és a hozzá tartozó **Content Editor** (CMS) felületet írja le nem műszaki felhasználóknak.
A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkcióváltozásnál a fejlesztő frissíti. A CMS ❓ Súgó menüpontja ezt a fájlt jeleníti meg.
---
## 1. A weboldal
### Hol érhető el?
- **Staging (teszt) oldal**: [https://stage.mozdit.hu](https://stage.mozdit.hu) — itt ellenőrizhetők a friss változtatások éles környezetben, még a véglegesítés előtt.
- A staging oldal tetején **sárga figyelmeztető sáv** jelzi, hogy tesztkörnyezetet látsz.
### Oldalak
- **Kezdőlap** — `https://stage.mozdit.hu/`
- **Rólunk** — `/rolunk`
- **Szolgáltatások** — `/szolgaltatasok`
- **Kapcsolat** — `/kapcsolat` (űrlap, ami beérkező üzenetként tárolódik)
- **Adatvédelmi tájékoztató** — `/adatvedelem`
- **Felhasználási feltételek** — `/felhasznalasi-feltetelek`
### Hogyan változik a weboldal tartalma?
1. A szerkesztő a **Content Editorban** módosítja a szövegeket (2. fejezet).
2. **💾 Mentés** — a módosítás elmentődik, azonnali biztonsági mentéssel.
3. **🚀 Publikálás** — a változtatás bekerül a Git repóba, és automatikusan deployol a staging oldalra.
4. Az éles (production) weboldalra a tartalom csak ellenőrzött, szándékos deploy lépéssel kerül fel — a CMS-ből soha nem publisholódik automatikusan productionre.
---
## 2. Content Editor (CMS)
### Belépés és kilépés
- A CMS a kiadott címen érhető el (staging: `https://cms.stage.llmdev.mozdit.hu`).
- **Bejelentkezés**: a logós bejelentkező oldalon add meg a **felhasználónevet és jelszót** (ezt az adminisztrátor adja), majd kattints a Belépés gombra.
- Több **sikertelen próbálkozás** (5) után a belépés kb. 15 percre zárolásra kerül.
- A belépés **8 óráig érvényes** — ezután a CMS visszairányít a bejelentkező oldalra, ahol újra meg kell adni a jelszót.
- **🚪 Kilépés**: az alsó sáv gombja — egy megerősítő kérdés („Biztosan ki szeretnél lépni?") után kijelentkezel, és megjelenik a bejelentkező oldal.
### Felület áttekintés
- **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, 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
- A mezők fölötti **útvonal** (pl. `hero.title`) jelzi, hol jelenik meg a szöveg az oldalon.
- Mezőtípusok:
- **Egysoros / több soros szövegmező** — általános szöveg; a hosszabb szöveg automatikusan nagyobb mezőben szerkeszthető.
- **Jelölőnégyzet** — be/ki (igen/nem) érték.
- **Számmező** — numerikus érték.
- A módosítás **nem kerül azonnal az oldalra** — ahhoz Mentés, majd Publikálás kell.
### Listák szerkesztése
- Lista elem (pl. egy jelszó, egy szolgáltatás tulajdonság): **❌ gombbal törölhető**.
- ** Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal).
- Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal.
### 🎨 Logó kezelése
- Az alsó sáv **🎨 Logó** gombja megnyitja a logókezelő oldalt.
- Két logó cserélhető: a **weboldal fejléclogója** (szöveges) és a **CMS bejelentkező oldal ikonja**.
- **Partner logó feltöltése**: ugyanitt — a fájlnév megadása után a feltöltött PNG a `/partners/…` elérési útra kerül; ezt az utat másold a partner **logo** mezőjébe.
- Csak **PNG**, max. **1 MB**; ajánlott átlátszó háttér a sötét fejléchez.
- A régi logó mentésre kerül — a csere biztonságos és visszavonható (a mentések a `.content-backups` mappában).
- A **CMS azonnal** az új logót mutatja; a **weboldalon a Publikálás (deploy) után** jelenik meg.
### 🤝 Partnerek
- A kezdőlap **„Partnereink"** szekciója a Szolgáltatások alatt jelenik meg (logó + név + hivatkozás).
- A **Kezdőlap** fülön a `partners.items` listában szerkeszthetők: `name` (név), `url` (hivatkozás), `logo` (elérési út, pl. `/partners/acme.png`).
- Új logó: a **🎨 Logó** oldalon töltsd fel, a visszaadott utat illeszd a `logo` mezőbe.
### ⌨️ Gyorsbillentyűk
- **Ctrl/Cmd + S** — Mentés
- **Ctrl/Cmd + P** — Publikálás
- **Ctrl/Cmd + Shift + V** — Verziók panel megnyitása új fülön
- **?** — gyorsbillentyű-súgó megjelenítése (Esc vagy kattintás zárja)
A gyorsbillentyűk csak a szerkesztő főoldalán működnek; beviteli mezőben gépelve a normál karakterként viselkednek.
### 🕘 Verziók — korábbi állapotok
- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít).
- **⚖ Összehasonlítás**: megmutatja, mi változott az adott mentéshez képest (piros = a mentésben lévő régi szöveg, zöld = a jelenlegi).
- **↩ Visszaállítás**: egy kattintással visszaállítja a mentést. A visszaállítás **előtt a jelenlegi tartalom is mentésre kerül**, tehát a visszaállítás is visszavonható.
- A visszaállítás sémaillesztésen megy át — hibás mentést nem lehet visszaállítani.
- Visszaállítás után a nyitott szerkesztő fülek frissítést kérnek (a tartalom megváltozott).
### 💾 Mentés
- A Mentés **ellenőrzi a tartalmat**: hiányzó vagy rossz típusú mező esetén hibaüzenetet kapsz, és a mentés nem történik meg — az oldal így nem tud elromlani.
- **Ha a tartalom megváltozott, mióta a lapot megnyitottad** (pl. közben deploy történt vagy egy másik fülben mentett valaki), a Mentés figyelmeztet: ilyenkor döntsd el, hogy frissíted a lapot az új tartalomra (a szerkesztésed elvész), vagy megszakítod. Ezzel a védelemmel nem írható véletlenül felül senki módosítása.
- Minden sikeres mentés **biztonsági mentést** készít a szerveren (`.content-backups/`), és naplózza a műveletet.
- Ha a Mentés sikeres, a mentett állapotot **Előnézet** gombbal nézheted meg a staging oldalon.
### 🚀 Publikálás
- A Publikálás **commitolja és feltolja** a változtatásokat, majd elindítja a staging deployt.
- „Nincs új változtatás." üzenet: nincs új mentett változtatás — ez **nem hiba**, ilyenkor deploy sem indul.
- A publikálás korlátozva van (3 próbálkozás / 15 perc) a véletlen tömeges deploy elkerülésére.
- A deploy eltarthat 1-2 percig; az eredményt az Előnézet gombbal ellenőrizheted.
### Biztonság
- Több **sikertelen belépési kísérlet** után a rendszer átmenetileg letiltja a belépést a gépedről (kb. 15 percre).
- Minden mentés és publikálás **naplózva** van (audit log) a nyomonkövethetőség érdekében.
---
## Karbantartás (fejlesztőknek)
- Forrás: `docs/felhasznaloi-utmutato.md` — a CMS a `/guide` útvonalon rendereli ki.
- **Szabály**: minden CMS- vagy honlapfunkció változásnál frissítsd ezt a fájlt ugyanabban a commitban.
- Az útmutató támogatott formátuma: címsorok, **félkövér**, `kód`, listák, linkek, elválasztó vonalak.
+70
View File
@@ -0,0 +1,70 @@
/**
* MITHOME-93 biztonsági lépés: a régi egyedi CMS (content-editor.js +
* scripts/cms-*.js) eltávolítása előtt exportálja a Payload jelenlegi
* (publikált) szöveges tartalmát egy olvasható JSON fájlba — mindkét
* locale-lal (hu, en) —, hogy git-committolt, ember által is átnézhető
* biztonsági mentés maradjon a leépítés pillanatáról.
*
* NEM helyettesíti a git history-t (a src/content/*.json fájlok minden
* korábbi szerkesztése megvan commit-onként), és nem helyettesíti a
* MongoDB-t (az az élő forrás) — ez egy plusz, könnyen olvasható
* pillanatkép a "mielőtt törlünk, mentsünk" elv jegyében.
*
* Futtatás (proto/ mappából, futó MongoDB-vel és beállított env-ekkel):
* node --env-file=.env.local --import tsx scripts/export-content-snapshot.ts
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { getPayload, type Payload } from 'payload'
import config from '../src/payload.config'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const outDir = path.resolve(scriptDir, '../../docs/backups')
const GLOBAL_SLUGS = ['home', 'about', 'services', 'contact', 'common'] as const
const LOCALES = ['hu', 'en'] as const
async function exportGlobals(payload: Payload) {
const result: Record<string, Record<string, unknown>> = {}
for (const slug of GLOBAL_SLUGS) {
result[slug] = {}
for (const locale of LOCALES) {
result[slug][locale] = await payload.findGlobal({ slug, locale })
}
}
return result
}
async function exportCollection(payload: Payload, collection: 'legal-pages' | 'partners') {
const result: Record<string, unknown> = {}
for (const locale of LOCALES) {
const { docs } = await payload.find({ collection, locale, limit: 1000 })
result[locale] = docs
}
return result
}
async function run() {
const payload = await getPayload({ config })
const snapshot = {
exportedAt: new Date().toISOString(),
reason: 'MITHOME-93 — régi CMS (content-editor.js) leépítése előtti biztonsági mentés',
globals: await exportGlobals(payload),
collections: {
legalPages: await exportCollection(payload, 'legal-pages'),
partners: await exportCollection(payload, 'partners'),
},
}
fs.mkdirSync(outDir, { recursive: true })
const filename = `payload-content-snapshot-${snapshot.exportedAt.replace(/[:.]/g, '-')}.json`
const outPath = path.join(outDir, filename)
fs.writeFileSync(outPath, JSON.stringify(snapshot, null, 2), 'utf8')
payload.logger.info(`Snapshot kiírva: ${outPath}`)
process.exit(0)
}
run()
@@ -1,91 +0,0 @@
/**
* Regression test for the Content Editor browser script: deleting an array
* item via its ❌ button must reindex the remaining items, otherwise collect()
* produces sparse arrays (null holes) that fail schema validation
* ("$.details.services[1].specs.items[0]: string érték szükséges").
*
* Runs the REAL scripts/cms-editor-client.js in jsdom and clicks the actual
* delete buttons — earlier coverage only exercised reindexItems() directly,
* which missed that the onclick handler removed the node BEFORE looking up
* its container (detached node → closest() === null → no reindex).
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
const service = (n: number) => ({
id: `svc-${n}`,
title: `Szolgáltatás ${n}`,
description: `Leírás ${n}`,
icon: '🔧',
features: [`feature ${n}`],
ctaText: 'CTA',
})
const makeData = () => ({
details: {
title: 'Részletek',
subtitle: 'Alcím',
services: [
{ icon: 'a', title: 's0', description: 'd0', specs: { title: 't0', items: ['a0', 'b0', 'c0'] } },
{ icon: 'b', title: 's1', description: 'd1', specs: { title: 't1', items: ['a1', 'b1', 'c1'] } },
{ icon: 'c', title: 's2', description: 'd2', specs: { title: 't2', items: ['a2', 'b2', 'c2'] } },
],
},
})
function bootClient(data: unknown) {
;(global as any).DATA = data
;(global as any).FILE = 'services'
;(global as any).CSRF_TOKEN = 'test-token'
;(global as any).fetch = jest.fn()
document.body.innerHTML = '<div id="editor"></div>'
// sloppy-mode eval publishes the script's functions on the global object
;(0, eval)(clientJs)
}
function deleteButtonFor(dataPath: string): HTMLButtonElement {
const field = document.querySelector(`[data-path="${CSS.escape(dataPath)}"]`) as HTMLElement
expect(field).not.toBeNull()
const wrap = field.closest('.str-item') as HTMLElement
expect(wrap).not.toBeNull()
return wrap.querySelector('.btn-del') as HTMLButtonElement
}
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
})
describe('Content Editor client delete/reindex', () => {
it('deleting a nested string array item keeps the remaining items dense', () => {
const data = makeData()
bootClient(data)
deleteButtonFor('details.services[1].specs.items[0]').click()
const collected = (global as any).collect()
expect(collected.details.services[1].specs.items).toEqual(['b1', 'c1'])
expect(collected.details.services[0].specs.items).toEqual(['a0', 'b0', 'c0'])
expect(collected.details.services[2].specs.items).toEqual(['a2', 'b2', 'c2'])
})
it('deleting an object card reindexes the outer array', () => {
const data = makeData()
bootClient(data)
const cardHeader = Array.from(document.querySelectorAll('.card-header'))
.find(h => h.textContent === 'details.services[1]') as HTMLElement
expect(cardHeader).not.toBeNull()
const card = cardHeader.closest('.obj-card') as HTMLElement
;(card.querySelector('.btn-del-card') as HTMLButtonElement).click()
const collected = (global as any).collect()
expect(collected.details.services).toHaveLength(2)
expect(collected.details.services[0].title).toBe('s0')
expect(collected.details.services[1].title).toBe('s2')
expect(collected.details.services[1].specs.items).toEqual(['a2', 'b2', 'c2'])
})
})
@@ -1,102 +0,0 @@
/**
* Regression tests for the Content Editor keyboard shortcuts (MITHOME-75).
* Runs the real scripts/cms-editor-client.js in jsdom, dispatches actual
* KeyboardEvents and asserts:
* 1. Ctrl+S calls save (fetch /save) and the browser save dialog is
* suppressed (preventDefault)
* 2. Ctrl+P calls publish
* 3. Ctrl+Shift+V opens the versions page in a new tab
* 4. '?' opens the shortcuts overlay, Esc closes it
* 5. plain 's' inside an input does NOT trigger save
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-shortcuts.js'), 'utf8')
const makeData = () => ({
hero: { title: 'T', subtitle: 'S', description: 'D' },
})
function press(target: Document | Element, init: KeyboardEventInit) {
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
target.dispatchEvent(ev)
return ev
}
const flush = () => new Promise(r => setTimeout(r, 0))
// Globals must exist BEFORE the eval — the client script boots immediately
// (render(DATA, …)). The eval runs ONCE: every eval would add another keydown
// listener to the shared jsdom document, and the toggle-style overlay handler
// would then open/close itself multiple times per keypress.
;(global as any).DATA = makeData()
;(global as any).FILE = 'home'
;(global as any).CSRF_TOKEN = 'boot'
;(global as any).CONTENT_HASH = 'x'.repeat(64)
;(global as any).fetch = jest.fn(async () => ({ status: 200, ok: true, json: async () => ({ ok: true }) }))
;(global as any).window = global
document.body.innerHTML = '<div id="editor"></div>'
;(0, eval)(clientJs)
describe('Content Editor keyboard shortcuts', () => {
let fetchCalls: Array<{ url: string; init?: RequestInit }>
beforeEach(() => {
fetchCalls = []
;(global as any).DATA = makeData()
;(global as any).FILE = 'home'
;(global as any).CSRF_TOKEN = 'test-csrf'
;(global as any).CONTENT_HASH = 'x'.repeat(64)
;(global as any).fetch = jest.fn(async (url: string, init?: RequestInit) => {
fetchCalls.push({ url, init })
return { status: 200, ok: true, json: async () => ({ ok: true, contentHash: 'y'.repeat(64) }) }
})
;(global as any).window = global
;(window as any).open = jest.fn()
document.body.innerHTML = '<div id="editor"></div><span id="saveStatus" style="display:none"></span><button id="publishBtn">pub</button>'
})
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
delete (global as any).CONTENT_HASH
})
it('Ctrl+S saves via fetch and suppresses the browser save dialog', () => {
const ev = press(document, { key: 's', ctrlKey: true })
expect(ev.defaultPrevented).toBe(true)
expect(fetchCalls.length).toBeGreaterThanOrEqual(1)
expect(fetchCalls[0].url).toContain('/save?file=home')
})
it('Cmd+P publishes', async () => {
const ev = press(document, { key: 'p', metaKey: true })
expect(ev.defaultPrevented).toBe(true)
await flush() // publish awaits save() before its own fetch
expect(fetchCalls.some(c => c.url === '/publish')).toBe(true)
})
it('Ctrl+Shift+V opens the versions page in a new tab', () => {
const ev = press(document, { key: 'V', ctrlKey: true, shiftKey: true })
expect(ev.defaultPrevented).toBe(true)
expect((window as any).open).toHaveBeenCalledWith('/versions?file=home', '_blank')
})
it("'?' opens the shortcuts overlay and Esc closes it", () => {
press(document, { key: '?' })
expect(document.getElementById('shortcuts-overlay')).not.toBeNull()
press(document, { key: 'Escape' })
expect(document.getElementById('shortcuts-overlay')).toBeNull()
})
it('plain typing in an input never triggers save', () => {
const input = document.createElement('input')
document.body.appendChild(input)
const ev = press(input, { key: 's' })
expect(ev.defaultPrevented).toBe(false)
expect(fetchCalls.length).toBe(0)
})
})
+6 -2
View File
@@ -1,5 +1,9 @@
// Shared runtime schema for the content JSON files.
// Kept dependency-free so it can run in both Next.js and content-editor.js.
// Shared runtime schema for the content JSON files (proto/src/content/*.json).
// Kept dependency-free — used by src/content/index.ts (test fixtures for
// Header/Footer, MITHOME-96) and scripts/test-content-schema.js. The JSON
// files themselves remain the source for scripts/migrate-content-to-payload.ts.
// The standalone content-editor.js CMS that used to run this too was retired
// in MITHOME-93 (superseded by Payload CMS).
const string = { type: 'string' };
const boolean = { type: 'boolean' };
const array = items => ({ type: 'array', items });
-135
View File
@@ -1,135 +0,0 @@
// Security and infrastructure helpers for the Content Editor, extracted so
// content-editor.js stays focused on HTTP routing (file-size limits).
// Dependencies (validateLogin, hasValidSession) are injected to avoid cycles.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const CMS_USER = process.env.CMS_USER;
const CMS_PASS = process.env.CMS_PASS;
const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV;
const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex');
const rateLimits = new Map();
function securityConfigIsValid() {
return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV));
}
function getClientAddress(req) {
// The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header.
// WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client
// IP to the list, so the first entry may be a spoofed value sent by the client — using
// it would let attackers bypass the rate limiter with a fresh "IP" per request.
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
return req.socket.remoteAddress || 'unknown';
}
function isRateLimited(key, limit, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
if (attempts.length === 0) {
rateLimits.delete(key);
return false;
}
rateLimits.set(key, attempts);
return attempts.length >= limit;
}
function recordRateLimitAttempt(key, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
}
function exceedsRateLimit(key, limit, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
return attempts.length > limit;
}
function hasValidCredentials(req, validateLogin) {
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const str = Buffer.from(b64auth, 'base64').toString();
const colonIdx = str.indexOf(':');
const login = colonIdx !== -1 ? str.slice(0, colonIdx) : str;
const password = colonIdx !== -1 ? str.slice(colonIdx + 1) : '';
return validateLogin(login, password, CMS_USER, CMS_PASS);
}
function isBrowserNavigation(req) {
return req.method === 'GET' && String(req.headers.accept || '').includes('text/html');
}
// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them
// automatically, which would let an already-logged-out browser straight back in.
// Browser navigations therefore authenticate ONLY via the session cookie, so
// logout is final. Non-browser requests (curl, API clients) keep Basic Auth.
function makeIsAuthenticated(hasValidSession, validateLogin) {
return function isAuthenticated(req) {
if (isBrowserNavigation(req)) return hasValidSession(req);
return hasValidCredentials(req, validateLogin) || hasValidSession(req);
};
}
function hasValidCsrfToken(req) {
const token = req.headers['x-csrf-token'];
return typeof token === 'string'
&& token.length === CSRF_TOKEN.length
&& crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN));
}
function makeWriteAudit(auditFile) {
return function writeAudit(event, details = {}) {
const record = { timestamp: new Date().toISOString(), event, ...details };
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 });
};
}
function backupAndWriteAtomically(targetFile, data, backupDir) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`;
const backupFile = path.join(backupDir, backupName);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, backupFile);
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempFile, targetFile);
return backupFile;
}
const RATE_LIMIT_GC_INTERVAL_MS = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, attempts] of rateLimits) {
const valid = attempts.filter(t => now - t < 15 * 60 * 1000);
if (valid.length === 0) rateLimits.delete(key);
else rateLimits.set(key, valid);
}
}, RATE_LIMIT_GC_INTERVAL_MS).unref();
module.exports = {
CMS_USER,
CMS_PASS,
CMS_DEPLOY_ENV,
CSRF_TOKEN,
securityConfigIsValid,
getClientAddress,
isRateLimited,
recordRateLimitAttempt,
exceedsRateLimit,
hasValidCredentials,
isBrowserNavigation,
makeIsAuthenticated,
hasValidCsrfToken,
makeWriteAudit,
backupAndWriteAtomically,
};
-65
View File
@@ -1,65 +0,0 @@
// Dependency-free line diff (LCS) for the CMS version comparison view.
// Input lines are plain text; output entries are typed add/del/ctx rows.
function diffLines(oldLines, newLines) {
const n = oldLines.length;
const m = newLines.length;
// LCS lengths DP (files are small, a few hundred lines — O(n*m) is fine)
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
out.push({ type: 'ctx', text: oldLines[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'del', text: oldLines[i] });
i++;
} else {
out.push({ type: 'add', text: newLines[j] });
j++;
}
}
while (i < n) { out.push({ type: 'del', text: oldLines[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: newLines[j] }); j++; }
return out;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Keep only ±contextAround context lines around changes to keep pages small.
function trimContext(entries, contextAround = 3) {
const keep = new Array(entries.length).fill(false);
entries.forEach((e, idx) => {
if (e.type !== 'ctx') {
for (let k = Math.max(0, idx - contextAround); k <= Math.min(entries.length - 1, idx + contextAround); k++) keep[k] = true;
}
});
const out = [];
let skipping = false;
entries.forEach((e, idx) => {
if (keep[idx]) { out.push(e); skipping = false; }
else if (!skipping) { out.push({ type: 'skip', text: '…' }); skipping = true; }
});
return out;
}
function renderDiffHtml(oldText, newText) {
const entries = trimContext(diffLines(oldText.split('\n'), newText.split('\n')));
return entries.map(e => `<div class="diff-${e.type}">${escapeHtml(e.text) || '&nbsp;'}</div>`).join('\n');
}
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
-345
View File
@@ -1,345 +0,0 @@
// Browser-side script of the Content Editor editor page.
// Inlined into the HTML template at render time by content-editor.js.
// Test coverage: scripts/test-content-editor-serializer.js runs this exact code.
// ── Render ──────────────────────────────────────────────────────────────────
function render(obj, container) {
container.innerHTML = '';
renderObject(obj, container, '');
}
function renderObject(obj, container, prefix) {
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? prefix + '.' + key : key;
if (Array.isArray(val)) {
renderArray(key, val, container, path);
} else if (typeof val === 'object' && val !== null) {
renderObject(val, container, path);
} else {
renderPrimitive(path, val, container);
}
}
}
function renderPrimitive(path, val, container) {
const isLong = String(val).length > 80 || String(val).includes('<');
const div = document.createElement('div');
div.className = 'field';
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = `<input type="checkbox" data-path="${esc(path)}" data-type="boolean" ${val ? 'checked' : ''}>`;
} else if (type === 'number') {
control = `<input type="number" data-path="${esc(path)}" data-type="number" value="${esc(val)}">`;
} else {
control = isLong
? `<textarea data-path="${esc(path)}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${esc(path)}" data-type="${type}" value="${esc(val ?? '')}">`;
}
div.innerHTML = `
<label>${path}</label>
${control}
`;
container.appendChild(div);
}
function renderArray(key, arr, container, path) {
const section = document.createElement('div');
section.className = 'array-section';
section.dataset.arrayPath = path;
const label = document.createElement('div');
label.className = 'array-label';
label.textContent = path;
section.appendChild(label);
const items = document.createElement('div');
items.className = 'array-items';
items.dataset.arrayItems = path;
section.appendChild(items);
arr.forEach((item, i) => {
if (typeof item === 'object' && item !== null) {
items.appendChild(makeObjCard(item, i, path));
} else {
items.appendChild(makeStrItem(item, i, path));
}
});
// Template for adding new items
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
const isObj = typeof sample === 'object' && sample !== null;
const addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.textContent = ' Új elem hozzáadása';
addBtn.onclick = () => {
const idx = items.children.length;
if (isObj) {
const blank = blankLike(sample);
items.appendChild(makeObjCard(blank, idx, path));
} else {
items.appendChild(makeStrItem('', idx, path));
}
reindexItems(items);
};
section.appendChild(addBtn);
container.appendChild(section);
}
function blankLike(value) {
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
}
if (typeof value === 'boolean') return false;
if (typeof value === 'number') return 0;
return '';
}
function makeStrItem(val, idx, path) {
const wrap = document.createElement('div');
wrap.className = 'str-item';
const type = val === null ? 'null' : typeof val;
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
ta.dataset.path = path + '[' + idx + ']';
ta.dataset.type = type;
if (type === 'boolean') {
ta.type = 'checkbox';
ta.checked = val;
} else {
ta.value = val ?? '';
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
}
const del = document.createElement('button');
del.className = 'btn-del';
del.textContent = '❌';
del.title = 'Törlés';
del.onclick = () => {
// WHY: capture the container BEFORE removing — a detached node has no
// ancestors, so closest() would return null and reindexing would silently
// not run (sparse arrays → schema errors on save).
const container = wrap.closest('.array-items');
wrap.remove();
reindexItems(container);
};
wrap.appendChild(ta);
wrap.appendChild(del);
return wrap;
}
function makeObjCard(obj, idx, path) {
const card = document.createElement('div');
card.className = 'obj-card';
const hdr = document.createElement('div');
hdr.className = 'card-header';
hdr.textContent = path + '[' + idx + ']';
card.appendChild(hdr);
renderObject(obj, card, path + '[' + idx + ']');
const del = document.createElement('button');
del.className = 'btn-del-card';
del.textContent = '❌ Törlés';
del.onclick = () => {
// Same as above: capture before detaching, or reindexing is skipped.
const container = card.closest('.array-items');
card.remove();
reindexItems(container);
};
card.appendChild(del);
return card;
}
function reindexItems(itemsEl) {
if (!itemsEl) return;
const path = itemsEl.dataset.arrayItems;
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
// services[1].specs.items rewrites the OUTER services index and scatters paths
// across services[0..n], producing sparse arrays and schema errors).
const prefix = path + '[';
Array.from(itemsEl.children).forEach((child, i) => {
child.querySelectorAll('[data-path]').forEach(el => {
const old = el.dataset.path;
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
const rest = old.slice(prefix.length);
const bracketEnd = rest.indexOf(']');
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
el.dataset.path = prefix + i + suffix;
});
// Update card header
const hdr = child.querySelector('.card-header');
if (hdr) hdr.textContent = path + '[' + i + ']';
});
}
// ── Collect & Save ───────────────────────────────────────────────────────────
function collect() {
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
// Wipe all arrays so we rebuild them from DOM
clearArrays(result);
document.querySelectorAll('[data-path]').forEach(el => {
setPath(result, el.dataset.path, readValue(el));
});
return result;
}
function readValue(el) {
switch (el.dataset.type) {
case 'boolean': return el.checked;
case 'number': return Number(el.value);
case 'null': return el.value === '' ? null : el.value;
default: return el.value;
}
}
function clearArrays(obj) {
for (const k of Object.keys(obj)) {
if (Array.isArray(obj[k])) obj[k] = [];
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
}
}
function setPath(obj, path, value) {
const parts = parsePath(path);
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (cur[part] === undefined || cur[part] === null) {
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function parsePath(path) {
const parts = [];
let token = '';
let inIndex = false;
for (const char of path) {
if (char === '.') {
if (!inIndex && token) parts.push(token);
token = '';
} else if (char === '[') {
if (token) parts.push(token);
token = '';
inIndex = true;
} else if (char === ']') {
parts.push(Number(token));
token = '';
inIndex = false;
} else {
token += char;
}
}
if (token) parts.push(token);
return parts;
}
async function save() {
const status = document.getElementById('saveStatus');
try {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return false; }
if (res.status === 409) {
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
location.reload();
}
return false;
}
const json = await res.json();
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
return true;
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
} catch (e) {
status.textContent = '❌ Hálózati hiba mentéskor';
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
}
async function publish() {
const btn = document.getElementById('publishBtn');
const status = document.getElementById('saveStatus');
// Save first — abort publish if save failed (e.g. 409 conflict, validation error)
const saved = await save();
if (!saved) return;
// WHY: lock the button width and remember the label so the running state
// neither resizes the bottom bar nor permanently swaps the env-specific label.
const originalLabel = btn.textContent;
btn.style.minWidth = btn.offsetWidth + 'px';
btn.textContent = '⏳ Élesítés folyamatban...';
btn.disabled = true;
try {
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
status.style.color = '#f87171';
}
} catch (e) {
status.textContent = '❌ Hálózati hiba';
status.style.color = '#f87171';
}
btn.textContent = originalLabel;
btn.style.minWidth = '';
btn.disabled = false;
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
}
async function logout() {
if (!confirm('Biztosan ki szeretnél lépni?')) return;
try {
// Invalidates the server-side session cookie (Basic Auth cache is not
// affected — the login page is public, no 401-overwrite is needed).
await fetch('/logout', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
} catch (e) { /* network error — continue to the login page */ }
location.href = '/login';
}
function esc(v) {
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Boot
render(DATA, document.getElementById('editor'));
// Auto-dismiss toast
const toast = document.querySelector('.toast');
if (toast) setTimeout(() => toast.remove(), 3500);
-56
View File
@@ -1,56 +0,0 @@
// Keyboard shortcuts for the Content Editor editor page. Inlined after the
// main client script; all referenced functions are global at that point.
// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help
// Plain typing in inputs never triggers actions — the handler requires the
// modifier key (or, for '?', a non-editing target).
function showShortcutsOverlay() {
const existing = document.getElementById('shortcuts-overlay');
if (existing) { existing.remove(); return; }
const overlay = document.createElement('div');
overlay.id = 'shortcuts-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;';
overlay.innerHTML = `
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;">⌨️ Gyorsbillentyűk</h2>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> — Mentés</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> — Publikálás</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> — Verziók</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> — ez a súgó (Esc: bezárás)</div>
</div>`;
overlay.addEventListener('click', () => overlay.remove());
document.body.appendChild(overlay);
}
document.addEventListener('keydown', e => {
// Esc closes the shortcut overlay if open
if (e.key === 'Escape') {
const overlay = document.getElementById('shortcuts-overlay');
if (overlay) { overlay.remove(); e.preventDefault(); }
return;
}
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
save();
return;
}
if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) {
e.preventDefault();
publish();
return;
}
if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
window.open('/versions?file=' + encodeURIComponent(FILE), '_blank');
return;
}
if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') {
const target = e.target;
const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
if (!isEditing) {
e.preventDefault();
showShortcutsOverlay();
}
}
});
-345
View File
@@ -1,345 +0,0 @@
function setMsg(target, text, ok) {
const el = document.getElementById('msg-' + target);
el.textContent = text;
el.className = 'msg ' + (ok ? 'ok' : 'err');
}
async function upload(target) {
const file = document.getElementById('file-' + target).files[0];
const btn = document.getElementById('btn-' + target);
const origText = btn.textContent;
const msg = t => setMsg(target, t, false);
if (!file) { msg('Először válassz egy új PNG fájlt a mentéshez.'); return; }
if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; }
if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; }
btn.disabled = true;
btn.textContent = '⏳ Mentés folyamatban...';
try {
const bytes = new Uint8Array(await file.arrayBuffer());
await sendLogoBinary(target, bytes);
} catch (e) { msg('❌ Hálózati hiba mentés közben'); }
btn.disabled = false;
btn.textContent = origText;
}
async function sendLogoBinary(target, bytes) {
const res = await fetch('/logo?target=' + target, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
setMsg(target, '✅ Logó sikeresen elmentve! (A weboldalon a Publikálás után jelenik meg.)', true);
const variantParam = target === 'header' ? 'variant=header&' : '';
document.getElementById('prev-' + target).src = '/logo.png?' + variantParam + 't=' + Date.now();
const meta = document.getElementById('meta-' + target);
if (meta) meta.textContent = 'Módosítva (' + (bytes.length / 1024).toFixed(1) + ' KB) — elmentve';
} else setMsg(target, '❌ ' + json.error, false);
}
document.querySelectorAll('input[type=file]').forEach(inp => {
inp.addEventListener('change', () => {
const target = inp.id.replace('file-', '');
const f = inp.files[0];
const meta = document.getElementById('meta-' + target);
const prev = document.getElementById('prev-' + target);
if (!f) return;
if (f.type !== 'image/png') {
setMsg(target, 'Csak PNG formátumú kép választható ki.', false);
if (meta) meta.textContent = '';
return;
}
if (f.size > 1024 * 1024) {
setMsg(target, 'A fájl nagyobb 1 MB-nál.', false);
if (meta) meta.textContent = '';
return;
}
setMsg(target, 'Új fájl kiválasztva. Kattints a Mentés vagy a ✏️ Szerkesztés gombra.', true);
if (meta) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB (még nincs mentve)';
prev.src = URL.createObjectURL(f);
});
});
/* ── Interactive Canvas Editor Logic ────────────────────────────── */
let currentEditTarget = 'header';
let editImg = new Image();
let editState = {
zoom: 1, panX: 0, panY: 0, rotation: 0,
flipH: 1, flipV: 1, padding: 0, aspect: 0,
brightness: 100, contrast: 100, invert: false
};
const canvas = document.getElementById('edit-canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('canvas-wrap');
let isDragging = false, startX = 0, startY = 0;
function openEditor(target) {
currentEditTarget = target;
document.getElementById('modal-title').textContent = '🎨 Logó szerkesztése — ' + (target === 'header' ? 'Weboldal fejléc' : 'CMS ikon');
editState.aspect = (target === 'icon' ? 1 : 0);
updateAspectBtns();
resetFilters();
resetPan();
const fileInput = document.getElementById('file-' + target);
if (fileInput.files && fileInput.files[0]) {
const reader = new FileReader();
reader.onload = e => { loadImg(e.target.result); };
reader.readAsDataURL(fileInput.files[0]);
} else {
const previewSrc = document.getElementById('prev-' + target).src;
loadImg(previewSrc);
}
}
function loadImg(src) {
editImg = new Image();
editImg.crossOrigin = 'anonymous';
editImg.onload = () => {
document.getElementById('editor-modal').classList.add('open');
fitToCrop();
render();
};
editImg.src = src;
}
function closeEditor() {
document.getElementById('editor-modal').classList.remove('open');
}
function setAspect(ratio) {
editState.aspect = ratio;
updateAspectBtns();
render();
}
function updateAspectBtns() {
document.querySelectorAll('#aspect-btns button').forEach(b => {
const a = parseFloat(b.dataset.aspect);
b.classList.toggle('active', (editState.aspect === 0 && a === 0) || (Math.abs(editState.aspect - a) < 0.01));
});
}
function setZoom(val) {
editState.zoom = parseFloat(val);
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
render();
}
function setPadding(val) {
editState.padding = parseInt(val, 10);
document.getElementById('pad-val').textContent = editState.padding + 'px';
render();
}
function setFilter(name, val) {
editState[name] = parseInt(val, 10);
document.getElementById(name.slice(0, 6) + '-val').textContent = val + '%';
render();
}
function toggleInvert() {
editState.invert = !editState.invert;
document.getElementById('btn-invert').classList.toggle('active', editState.invert);
render();
}
// WHY: many partner/site logos arrive with a solid (usually white) background.
// Removing it lets the logo sit cleanly on any page background. Pixels brighter
// than the threshold become transparent; a soft ramp just below it keeps the
// edges smooth instead of jagged.
function makeTransparent() {
if (!editImg.width) return;
const off = document.createElement('canvas');
off.width = editImg.width;
off.height = editImg.height;
const octx = off.getContext('2d');
octx.drawImage(editImg, 0, 0);
const imgData = octx.getImageData(0, 0, off.width, off.height);
const d = imgData.data;
const thresh = 235; // fully transparent above this luminance
const soft = 30; // smooth ramp below the threshold
for (let i = 0; i < d.length; i += 4) {
const lum = (d[i] + d[i + 1] + d[i + 2]) / 3;
if (lum > thresh) {
d[i + 3] = 0;
} else if (lum > thresh - soft) {
const t = (lum - (thresh - soft)) / soft; // 0..1
d[i + 3] = Math.round(d[i + 3] * (1 - t));
}
}
octx.putImageData(imgData, 0, 0);
const next = new Image();
next.onload = () => { editImg = next; render(); };
next.src = off.toDataURL('image/png');
}
function resetFilters() {
editState.brightness = 100; editState.contrast = 100; editState.invert = false; editState.padding = 0;
document.getElementById('bright-range').value = 100; document.getElementById('bright-val').textContent = '100%';
document.getElementById('contrast-range').value = 100; document.getElementById('contrast-val').textContent = '100%';
document.getElementById('pad-range').value = 0; document.getElementById('pad-val').textContent = '0px';
document.getElementById('btn-invert').classList.remove('active');
render();
}
function rotate(deg) {
editState.rotation = (editState.rotation + deg) % 360;
render();
}
function toggleFlip(dir) {
if (dir === 'h') editState.flipH *= -1;
if (dir === 'v') editState.flipV *= -1;
render();
}
function resetPan() {
editState.panX = 0; editState.panY = 0;
render();
}
function getCropRect() {
const cw = canvas.width, ch = canvas.height;
let rw = cw * 0.85, rh = ch * 0.85;
if (editState.aspect > 0) {
if (rw / rh > editState.aspect) rw = rh * editState.aspect;
else rh = rw / editState.aspect;
}
return { x: (cw - rw) / 2, y: (ch - rh) / 2, w: rw, h: rh };
}
function fitToCrop() {
if (!editImg.width || !editImg.height) return;
const crop = getCropRect();
const isRotated = Math.abs(editState.rotation) === 90 || Math.abs(editState.rotation) === 270;
const iw = isRotated ? editImg.height : editImg.width;
const ih = isRotated ? editImg.width : editImg.height;
const scale = Math.min(crop.w / iw, crop.h / ih);
editState.zoom = Math.max(0.3, Math.min(3, scale));
document.getElementById('zoom-range').value = editState.zoom;
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
editState.panX = 0; editState.panY = 0;
render();
}
function render() {
if (!editImg.width) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const crop = getCropRect();
// Draw image
ctx.save();
ctx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
ctx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
ctx.rotate((editState.rotation * Math.PI) / 180);
ctx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
ctx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
// Dark overlay outside crop rect
ctx.save();
ctx.fillStyle = 'rgba(15, 17, 23, 0.75)';
ctx.fillRect(0, 0, canvas.width, crop.y);
ctx.fillRect(0, crop.y + crop.h, canvas.width, canvas.height - (crop.y + crop.h));
ctx.fillRect(0, crop.y, crop.x, crop.h);
ctx.fillRect(crop.x + crop.w, crop.y, canvas.width - (crop.x + crop.w), crop.h);
// Crop border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(crop.x, crop.y, crop.w, crop.h);
ctx.restore();
}
// Drag & Pan handlers
wrap.addEventListener('mousedown', e => { isDragging = true; startX = e.clientX - editState.panX; startY = e.clientY - editState.panY; wrap.classList.add('grabbing'); });
window.addEventListener('mousemove', e => { if (!isDragging) return; editState.panX = e.clientX - startX; editState.panY = e.clientY - startY; render(); });
window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('grabbing'); });
wrap.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.05 : -0.05;
setZoom(Math.max(0.3, Math.min(3, editState.zoom + delta)));
document.getElementById('zoom-range').value = editState.zoom;
}, { passive: false });
async function saveEditedLogo() {
const crop = getCropRect();
const outCanvas = document.createElement('canvas');
outCanvas.width = Math.round(crop.w * 2); // 2x for retina sharpness
outCanvas.height = Math.round(crop.h * 2);
const octx = outCanvas.getContext('2d');
octx.save();
octx.scale(2, 2);
octx.translate(-crop.x, -crop.y);
octx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
octx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
octx.rotate((editState.rotation * Math.PI) / 180);
octx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
octx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
octx.restore();
const saveBtn = document.getElementById('modal-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = '⏳ Mentés folyamatban...';
outCanvas.toBlob(async blob => {
if (!blob) { alert('Hiba a kép exportálásakor'); saveBtn.disabled = false; return; }
try {
const bytes = new Uint8Array(await blob.arrayBuffer());
await sendLogoBinary(currentEditTarget, bytes);
closeEditor();
} catch (e) {
alert('Hiba történt a mentés során.');
}
saveBtn.disabled = false;
saveBtn.textContent = '💾 Szerkesztett logó mentése';
}, 'image/png');
}
async function uploadPartner() {
const name = document.getElementById('partner-name').value.trim();
const file = document.getElementById('file-partner').files[0];
const msg = document.getElementById('msg-partner');
const pathOut = document.getElementById('path-partner');
const btn = document.getElementById('btn-partner');
pathOut.textContent = '';
if (!name) { msg.textContent = '❌ Adj meg egy fájlnevet.'; msg.className = 'msg err'; return; }
if (!file) { msg.textContent = '❌ Válassz PNG fájlt.'; msg.className = 'msg err'; return; }
if (file.type !== 'image/png') { msg.textContent = '❌ Csak PNG tölthető fel.'; msg.className = 'msg err'; return; }
btn.disabled = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const res = await fetch('/partner-logo?name=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
msg.textContent = '✅ Feltöltve.';
msg.className = 'msg ok';
pathOut.textContent = 'Elérési út: ' + json.path;
} else {
msg.textContent = '❌ ' + json.error;
msg.className = 'msg err';
}
} catch (e) {
msg.textContent = '❌ Hálózati hiba';
msg.className = 'msg err';
}
btn.disabled = false;
}
-203
View File
@@ -1,203 +0,0 @@
// Branding page for the Content Editor: upload/replace and edit logos with
// interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters).
// The browser-side editor script is inlined from cms-logo-client.js.
const fs = require('fs');
const path = require('path');
const { LOGO_TARGETS } = require('./cms-logo');
const logoClientJs = fs.readFileSync(path.join(__dirname, 'cms-logo-client.js'), 'utf8');
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Logó kezelése</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 720px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 22px; }
.card { background: #1a2035; border: 1px solid #2d3748; border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
.card h2 { font-size: 16px; color: #93c5fd; margin-bottom: 4px; }
.card .where { color: #64748b; font-size: 13px; margin-bottom: 14px; }
.field-label { display: block; font-size: 13px; color: #94a3b8; margin: 12px 0 6px; }
#partner-name { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 9px 12px; font-size: 14px; margin-bottom: 10px; }
.path-out { font-family: monospace; font-size: 13px; color: #6ee7b7; margin-top: 10px; word-break: break-all; }
.preview { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 22px 22px; border: 1px solid #2d3748; border-radius: 10px; padding: 16px; margin-bottom: 14px; text-align: center; min-height: 90px; }
.preview img { max-width: 100%; max-height: 72px; }
input[type=file] { color: #94a3b8; font-size: 14px; margin-bottom: 12px; width: 100%; }
.meta { font-size: 13px; color: #94a3b8; min-height: 20px; margin-bottom: 12px; }
.actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.btn-save { background: linear-gradient(135deg,#059669,#10b981); color: #fff; border: none; border-radius: 8px; padding: 9px 18px; font-size: 14px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-edit { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-secondary { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .5; cursor: wait; }
.msg { font-size: 14px; margin-top: 12px; min-height: 20px; }
.ok { color: #6ee7b7; } .err { color: #fca5a5; }
/* Modal Styles */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85); backdrop-filter: blur(4px); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 14px; }
.modal-backdrop.open { display: flex; }
.modal-box { background: #1a2035; border: 1px solid #334155; border-radius: 14px; width: 100%; max-width: 860px; max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
.modal-header { padding: 12px 20px; border-bottom: 1px solid #2d3748; display: flex; align-items: center; justify-content: space-between; background: #141824; }
.modal-header h3 { font-size: 16px; color: #93c5fd; }
.btn-close { background: transparent; border: none; color: #94a3b8; font-size: 18px; cursor: pointer; padding: 4px 8px; }
.modal-body { display: grid; grid-template-columns: 1fr 280px; gap: 16px; padding: 16px; overflow-y: auto; max-height: calc(92vh - 120px); }
@media (max-width: 720px) { .modal-body { grid-template-columns: 1fr; } }
.canvas-container { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 20px 20px; border: 1px solid #334155; border-radius: 10px; display: flex; align-items: center; justify-content: center; min-height: 320px; position: relative; overflow: hidden; cursor: grab; user-select: none; }
.canvas-container.grabbing { cursor: grabbing; }
canvas { max-width: 100%; max-height: 100%; display: block; }
.editor-controls { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: #cbd5e1; }
.ctrl-group { background: #141824; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; }
.ctrl-group h4 { font-size: 12px; text-transform: uppercase; color: #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px; }
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.btn-tool { background: #1e293b; border: 1px solid #475569; color: #cbd5e1; border-radius: 6px; padding: 5px 9px; font-size: 12px; cursor: pointer; }
.btn-tool.active { background: #2563eb; color: #fff; border-color: #3b82f6; }
.slider-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.slider-row label { width: 68px; font-size: 12px; color: #94a3b8; }
.slider-row input[type=range] { flex: 1; accent-color: #3b82f6; }
.slider-row span { width: 38px; font-size: 11px; text-align: right; color: #cbd5e1; }
.modal-footer { padding: 12px 20px; border-top: 1px solid #2d3748; background: #141824; display: flex; justify-content: flex-end; gap: 10px; align-items: center; }
</style>
</head>
<body>
<header>
<h1>🎨 Logó kezelése</h1>
<a href="/">← Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Csak <strong>PNG</strong> fájl tölthető fel (max. 1 MB). A régi logóról automatikus biztonsági mentés készül. A szerkesztővel közvetlenül vágatod, méretezheted, forgathatod és korrigálhatod a logókat a mentés előtt. A változás a <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg.</p>
<div class="card">
<h2>Weboldal fejléc logója (szöveges)</h2>
<p class="where">Használat: weboldal fejléc — jelenlegi fájl: /${LOGO_TARGETS.header}</p>
<div class="preview"><img id="prev-header" src="/logo.png?variant=header&t=${Date.now()}" alt="fejléc logó előnézet"></div>
<input type="file" id="file-header" accept="image/png">
<div class="meta" id="meta-header"></div>
<div class="actions-row">
<button class="btn-save" id="btn-header" onclick="upload('header')">💾 Fejléc logó mentése</button>
<button class="btn-edit" onclick="openEditor('header')">✏️ Logó szerkesztése</button>
</div>
<p class="msg" id="msg-header"></p>
</div>
<div class="card">
<h2>CMS logó (ikon)</h2>
<p class="where">Használat: CMS bejelentkező oldal — jelenlegi fájl: /${LOGO_TARGETS.icon}</p>
<div class="preview"><img id="prev-icon" src="/logo.png?t=${Date.now()}" alt="ikon logó előnézet"></div>
<input type="file" id="file-icon" accept="image/png">
<div class="meta" id="meta-icon"></div>
<div class="actions-row">
<button class="btn-save" id="btn-icon" onclick="upload('icon')">💾 Ikon logó mentése</button>
<button class="btn-edit" onclick="openEditor('icon')">✏️ Logó szerkesztése</button>
</div>
<p class="msg" id="msg-icon"></p>
</div>
<div class="card">
<h2>Partner logó feltöltése</h2>
<p class="where">Használat: a Kezdőlap „Partnereink" szekciójához. A feltöltés után a visszaadott elérési utat másold a partner „logo" mezőjébe (pl. /partners/nev.png).</p>
<label for="partner-name" class="field-label">Fájlnév (szóközök nélkül, pl. „acme")</label>
<input type="text" id="partner-name" placeholder="acme">
<input type="file" id="file-partner" accept="image/png">
<div class="meta" id="meta-partner"></div>
<button class="btn-save" id="btn-partner" onclick="uploadPartner()">⬆ Partner logó feltöltése</button>
<p class="msg" id="msg-partner"></p>
<p class="path-out" id="path-partner"></p>
</div>
</main>
<!-- Editor Modal -->
<div class="modal-backdrop" id="editor-modal">
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title">🎨 Logó szerkesztése</h3>
<button class="btn-close" onclick="closeEditor()">✕</button>
</div>
<div class="modal-body">
<div class="canvas-container" id="canvas-wrap">
<canvas id="edit-canvas" width="500" height="350"></canvas>
</div>
<div class="editor-controls">
<div class="ctrl-group">
<h4>Képarány / Vágás</h4>
<div class="btn-row" id="aspect-btns">
<button class="btn-tool" data-aspect="0" onclick="setAspect(0)">Szabad</button>
<button class="btn-tool active" data-aspect="1" onclick="setAspect(1)">1:1</button>
<button class="btn-tool" data-aspect="3" onclick="setAspect(3)">3:1</button>
<button class="btn-tool" data-aspect="4" onclick="setAspect(4)">4:1</button>
<button class="btn-tool" data-aspect="1.777" onclick="setAspect(1.777)">16:9</button>
</div>
</div>
<div class="ctrl-group">
<h4>Méret és Pozíció</h4>
<div class="slider-row">
<label>Nagyítás:</label>
<input type="range" id="zoom-range" min="0.3" max="3" step="0.05" value="1" oninput="setZoom(this.value)">
<span id="zoom-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" onclick="resetPan()">✛ Középre</button>
<button class="btn-tool" onclick="fitToCrop()">⊡ Keretbe illesztés</button>
</div>
</div>
<div class="ctrl-group">
<h4>Forgatás & Tükrözés</h4>
<div class="btn-row">
<button class="btn-tool" onclick="rotate(-90)">↺ Balra 90°</button>
<button class="btn-tool" onclick="rotate(90)">↻ Jobbra 90°</button>
<button class="btn-tool" onclick="toggleFlip('h')">⇋ Vízszintes</button>
<button class="btn-tool" onclick="toggleFlip('v')">⇅ Függőleges</button>
</div>
</div>
<div class="ctrl-group">
<h4>Margó / Padding</h4>
<div class="slider-row">
<label>Margó:</label>
<input type="range" id="pad-range" min="0" max="60" step="2" value="0" oninput="setPadding(this.value)">
<span id="pad-val">0px</span>
</div>
</div>
<div class="ctrl-group">
<h4>Képkorrekció</h4>
<div class="slider-row">
<label>Fényerő:</label>
<input type="range" id="bright-range" min="50" max="200" value="100" oninput="setFilter('brightness', this.value)">
<span id="bright-val">100%</span>
</div>
<div class="slider-row">
<label>Kontraszt:</label>
<input type="range" id="contrast-range" min="50" max="200" value="100" oninput="setFilter('contrast', this.value)">
<span id="contrast-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" id="btn-invert" onclick="toggleInvert()">🌓 Invertálás</button>
<button class="btn-tool" onclick="resetFilters()">↺ Alaphelyzet</button>
<button class="btn-tool" onclick="makeTransparent()">⚪ Háttér átlátszóvá (fehér)</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeEditor()">Mégse</button>
<button class="btn-save" id="modal-save-btn" onclick="saveEditedLogo()">💾 Szerkesztett logó mentése</button>
</div>
</div>
</div>
<script>
const CSRF_TOKEN = "${csrfToken}";
${logoClientJs}
</script>
</body>
</html>`;
module.exports = { LOGO_PAGE };
-156
View File
@@ -1,156 +0,0 @@
// Logo upload handling for the Content Editor: PNG validation, timestamped
// backup and atomic binary replace.
const fs = require('fs');
const path = require('path');
const MAX_LOGO_BYTES = 1024 * 1024; // 1 MiB — plenty for a logo
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// WHY fixed targets instead of a client-supplied filename: arbitrary write
// paths would be a traversal risk; the two known logos are the only assets
// the site consumes.
const LOGO_TARGETS = {
icon: 'mozdit_logo.png', // CMS login page
header: 'mozdit_logo_text.png', // website Header
};
function isPng(buffer) {
return Buffer.isBuffer(buffer) && buffer.length >= PNG_MAGIC.length && buffer.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
}
function saveLogoAtomically(publicDir, targetKey, buffer, backupDir) {
const fileName = LOGO_TARGETS[targetKey];
if (!fileName) throw new Error('Ismeretlen logó célpont');
const targetFile = path.join(publicDir, fileName);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${fileName}.${timestamp}.bak`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, path.join(backupDir, backupName));
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return { targetFile, backupName };
}
// WHY: partner logos are a variable set — the filename comes from the editor,
// so it must be sanitized to a safe slug (no traversal, no separators).
function slugifyName(raw) {
return String(raw)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function savePartnerLogo(publicDir, filename, buffer) {
const slug = slugifyName(filename) || `partner-${Date.now()}`;
const dir = path.join(publicDir, 'partners');
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
const targetFile = path.join(dir, `${slug}.png`);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return `/partners/${slug}.png`;
}
// WHY: route handling lives here so content-editor.js stays under the
// 400-line limit. Returns true when the request was handled.
function handleLogoRoutes({ req, res, u, publicDir, backupDir, writeAudit, clientAddress, user, logoPage }) {
if (req.method === 'GET' && u.pathname === '/branding') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(logoPage());
return true;
}
if (req.method === 'POST' && u.pathname === '/logo') {
const target = u.searchParams.get('target') || '';
if (!LOGO_TARGETS[target]) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Ismeretlen logó célpont.' }));
return true;
}
const chunks = [];
let total = 0;
let tooLarge = false;
req.on('data', c => {
total += c.length;
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
chunks.push(c);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
if (tooLarge) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
return;
}
if (!isPng(buffer)) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'invalid_type' });
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
return;
}
try {
const { backupName } = saveLogoAtomically(publicDir, target, buffer, backupDir);
writeAudit('logo_updated', { clientAddress, user, target, result: 'ok', backup: backupName });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: backupName }));
} catch (e) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
if (req.method === 'POST' && u.pathname === '/partner-logo') {
const filename = u.searchParams.get('name') || '';
if (!slugifyName(filename)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Adj meg egy érvényes fájlnevet.' }));
return true;
}
const chunks = [];
let total = 0;
let tooLarge = false;
req.on('data', c => {
total += c.length;
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
chunks.push(c);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
if (tooLarge) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
return;
}
if (!isPng(buffer)) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'invalid_type' });
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
return;
}
try {
const publicPath = savePartnerLogo(publicDir, filename, buffer);
writeAudit('partner_logo_upload', { clientAddress, user, result: 'ok', path: publicPath });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: publicPath }));
} catch (e) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
return false;
}
module.exports = { MAX_LOGO_BYTES, LOGO_TARGETS, isPng, saveLogoAtomically, savePartnerLogo, slugifyName, handleLogoRoutes };
-325
View File
@@ -1,325 +0,0 @@
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Page templates for the Content Editor. Kept separate so content-editor.js
// stays focused on routing/handling and below the file-size limits.
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, deployVersion) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${isStaging() ? 'STAGING — ' : ''}mozdIT Content Editor</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header span { color: #64748b; font-size: 13px; }
.tabs { display: flex; gap: 2px; padding: 14px 32px 0; border-bottom: 1px solid #2d3748; background: #13192a; }
.tab { text-decoration: none; color: #94a3b8; padding: 9px 16px; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 500; transition: all .2s; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
.tab:hover { color: #e2e8f0; background: #1e2535; }
.tab.active { color: #60a5fa; background: #0f1117; border-color: #2d3748; }
.page { max-width: 860px; margin: 28px auto 120px; padding: 0 24px; }
.hint { color: #475569; font-size: 12px; margin-bottom: 20px; }
/* Primitive field */
.field { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 16px; transition: border-color .2s; margin-bottom: 10px; }
.field:focus-within { border-color: #60a5fa; }
.field label { display: block; font-size: 11px; font-weight: 600; color: #60a5fa; text-transform: uppercase; letter-spacing:.05em; margin-bottom: 7px; font-family: monospace; }
.field input, .field textarea { width: 100%; background: transparent; border: none; outline: none; color: #e2e8f0; font-size: 14px; line-height: 1.6; resize: vertical; font-family: inherit; }
.field textarea { min-height: 52px; }
/* Array section */
.array-section { margin-bottom: 20px; }
.array-label { font-size: 12px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing:.06em; font-family: monospace; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.array-label::after { content:''; flex: 1; height: 1px; background: #2d3748; }
.array-items { display: flex; flex-direction: column; gap: 8px; }
/* Simple string array item */
.str-item { display: flex; gap: 8px; align-items: flex-start; }
.str-item textarea { flex: 1; background: #1a2035; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; color: #e2e8f0; font-size: 14px; font-family: inherit; outline: none; resize: vertical; min-height: 44px; transition: border-color .2s; }
.str-item textarea:focus { border-color: #60a5fa; }
/* Object array item (card) */
.obj-card { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; position: relative; }
.obj-card .card-header { font-size: 11px; color: #64748b; font-family: monospace; margin-bottom: 10px; }
.obj-card .inner-field { margin-bottom: 8px; }
.obj-card .inner-field:last-child { margin-bottom: 0; }
.obj-card .inner-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing:.05em; font-family: monospace; margin-bottom: 4px; }
.obj-card input, .obj-card textarea { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 6px; padding: 8px 10px; color: #e2e8f0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; transition: border-color .2s; }
.obj-card input:focus, .obj-card textarea:focus { border-color: #60a5fa; }
/* Buttons */
.btn-del { background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 13px; transition: all .2s; white-space: nowrap; flex-shrink: 0; }
.btn-del:hover { background: #3f1c1c; }
.btn-del-card { position: absolute; top: 10px; right: 10px; background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 12px; transition: all .2s; }
.btn-del-card:hover { background: #3f1c1c; }
.btn-add { background: transparent; border: 1px dashed #334155; color: #64748b; border-radius: 8px; padding: 9px 16px; cursor: pointer; font-size: 13px; width: 100%; text-align: center; transition: all .2s; margin-top: 6px; }
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; background: #1a1535; }
/* Bottom bar */
.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 { 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); }
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
.btn-publish:active { transform: translateY(0); }
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; }
.preview-link:hover { color: #94a3b8; }
/* WHY: the status slot always occupies the same flex space (visibility, not
display) so showing/hiding messages never shifts the other bar items. */
.save-status { flex: 1 1 0; min-width: 0; margin: 0 8px; font-size: 13px; font-weight: 500; visibility: hidden; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Toast */
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
.toast.ok { background: #064e3b; border: 1px solid #10b981; color: #6ee7b7; }
.toast.err { background: #450a0a; border: 1px solid #ef4444; color: #fca5a5; }
@keyframes slideIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
</style>
</head>
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${escHtml(message.text)}</div>` : ''}
<header>
<h1>mozdIT Content Editor</h1>
<span>— JSON fájlok szerkesztése vizuálisan</span>
</header>
<nav class="tabs">
${Object.entries(fileLabels).map(([k, l]) =>
`<a href="/?file=${k}" class="tab ${activeFile === k ? 'active' : ''}">${l}</a>`
).join('')}
</nav>
<div class="page">
<p class="hint">📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (). Mentés gomb menti a fájlt.</p>
<div id="editor"></div>
</div>
<div class="bottom-bar">
<button class="btn-save" onclick="save()">💾 Mentés</button>
<button class="btn-publish" onclick="publish()" id="publishBtn">🚀 Publikálás & ${isStaging() ? 'Staging deploy' : 'Élesítés'}</button>
<span class="save-status" id="saveStatus"></span>
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
<a href="/branding" target="_blank" class="preview-link">🎨 Logó</a>
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
</div>
<script id="page-data" type="application/json">${jsonData.replace(/<\//g, '<\\/')}</script>
<script>
const DATA = JSON.parse(document.getElementById('page-data').textContent);
const FILE = "${activeFile}";
const CSRF_TOKEN = "${csrfToken}";
let CONTENT_HASH = "${contentHash}";
${clientJs}
</script>
</script>
</body>
</html>`;
// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme.
const GUIDE_PAGE = (contentHtml) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Felhasználói útmutató</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.65; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 760px; margin: 0 auto; padding: 32px 24px; }
h1 { font-size: 24px; margin: 16px 0 12px; color: #f1f5f9; }
h2 { font-size: 20px; margin: 28px 0 10px; color: #93c5fd; border-bottom: 1px solid #2d3748; padding-bottom: 6px; }
h3 { font-size: 16px; margin: 20px 0 8px; color: #c4b5fd; }
h4 { font-size: 14px; margin: 16px 0 6px; color: #c4b5fd; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0 8px 22px; }
li { margin: 4px 0; }
a { color: #7dd3fc; }
code { background: #1e293b; border-radius: 4px; padding: 1px 6px; font-size: 0.9em; color: #fbbf24; }
pre { background: #1e293b; border: 1px solid #2d3748; border-radius: 8px; padding: 12px 16px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; color: #e2e8f0; }
hr { border: none; border-top: 1px solid #2d3748; margin: 24px 0; }
</style>
</head>
<body>
<header>
<h1>mozdIT — Felhasználói útmutató</h1>
<a href="/">← Vissza a szerkesztőhöz</a>
</header>
<main>
${contentHtml}
</main>
</body>
</html>`;
// Login page — simple logo page shown after logout (and for unauthenticated browser visits).
const LOGIN_PAGE = () => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT CMS — Belépés</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; position: fixed; top: 0; left: 0; right: 0; }
.card { background: linear-gradient(160deg,#1a1f2e,#252d40); border: 1px solid #2d3748; border-radius: 16px; padding: 40px 36px; width: 100%; max-width: 380px; box-shadow: 0 20px 50px rgba(0,0,0,.45); }
.logo { text-align: center; margin-bottom: 28px; }
.logo img { height: 56px; }
h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 4px; }
.subtitle { color: #94a3b8; font-size: 14px; text-align: center; margin-bottom: 26px; }
label { display: block; font-size: 13px; color: #94a3b8; margin: 14px 0 6px; }
input { width: 100%; background: #0f1117; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 11px 14px; font-size: 15px; }
input:focus { outline: none; border-color: #60a5fa; }
button { width: 100%; margin-top: 24px; background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 12px; font-size: 15px; font-weight: 700; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .6; cursor: wait; }
.error { color: #f87171; font-size: 14px; text-align: center; margin-top: 14px; min-height: 20px; }
</style>
</head>
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET</div>' : ''}
<div class="card">
<div class="logo"><img src="/logo.png" alt="mozdIT"></div>
<h1>Content Editor</h1>
<p class="subtitle">Belépés a tartalomszerkesztőbe</p>
<form onsubmit="return login(event)">
<label for="user">Felhasználónév</label>
<input id="user" name="user" autocomplete="username" autofocus required>
<label for="pass">Jelszó</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<button type="submit" id="btn">Belépés</button>
</form>
<p class="error" id="err"></p>
</div>
<script>
async function login(e) {
e.preventDefault();
const btn = document.getElementById('btn');
const err = document.getElementById('err');
btn.disabled = true; err.textContent = '';
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: document.getElementById('user').value, pass: document.getElementById('pass').value })
});
if (res.ok) { location.href = '/'; return; }
const json = await res.json().catch(() => ({}));
err.textContent = json.error || 'Sikertelen belépés — ellenőrizd a felhasználónevet és a jelszót.';
} catch (e2) {
err.textContent = 'Hálózati hiba — próbáld újra.';
}
btn.disabled = false;
}
</script>
</body>
</html>`;
// Version history page: lists automatic backups of the selected file with a
// diff view (?show=) and one-click restore (POST /restore).
const VERSIONS_PAGE = (fileKey, fileLabel, versions, diff, csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Verziók: ${fileLabel}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 18px; }
.ver { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.ver .when { font-family: monospace; font-size: 14px; color: #93c5fd; }
.ver .size { color: #64748b; font-size: 13px; }
.ver .actions { margin-left: auto; display: flex; gap: 8px; }
.btn { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; text-decoration: none; }
.btn:hover { background: #374151; }
.btn-restore { background: #14532d; border-color: #10b981; color: #6ee7b7; }
.btn-restore:hover { background: #166534; }
h2 { font-size: 16px; margin: 26px 0 10px; color: #93c5fd; }
.diff { background: #0f1420; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; font-family: monospace; font-size: 13px; overflow-x: auto; }
.diff div { padding: 1px 10px; white-space: pre-wrap; word-break: break-all; }
.diff-add { background: #064e3b; color: #6ee7b7; }
.diff-del { background: #450a0a; color: #fca5a5; text-decoration: line-through; }
.diff-skip { color: #475569; }
.diff-ctx { color: #94a3b8; }
.empty { color: #64748b; padding: 24px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>🕘 Verziók — ${fileLabel}</h1>
<a href="/?file=${fileKey}">← Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Minden Mentés automatikus másolatot készít. A ⚖ Összehasonlítás megmutatja az adott mentés és a <strong>jelenlegi</strong> tartalom különbségét (piros = mentésben volt, zöld = most van). A visszaállítás előtt a jelenlegi állapot is mentésre kerül, tehát a visszaállítás is visszavonható.</p>
${versions.length === 0 ? '<div class="empty">Ehhez a fájlhoz még nincs mentés.</div>' : versions.map(v => `
<div class="ver">
<span class="when">${v.when}</span>
<span class="size">${v.size} B</span>
<span class="actions">
<a class="btn" href="/versions?file=${fileKey}&show=${v.name}">⚖ Összehasonlítás</a>
<button class="btn btn-restore" onclick="restore('${v.name}')">↩ Visszaállítás</button>
</span>
</div>`).join('')}
${diff ? `
<h2>Különbség: mentés (${diff.when}) → jelenlegi tartalom</h2>
<div class="diff">${diff.diffHtml}</div>` : ''}
</main>
<script>
const CSRF_TOKEN = "${csrfToken}";
const FILE = "${fileKey}";
async function restore(name) {
if (!confirm('Biztosan visszaállítod ezt a mentést?\\nA jelenlegi tartalom mentésre kerül, így ez később is visszavonható.')) return;
try {
const res = await fetch('/restore?file=' + FILE + '&backup=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF_TOKEN }
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) { alert('✅ Visszaállítva.'); location.href = '/?file=' + FILE; }
else alert('❌ Hiba: ' + json.error);
} catch (e) { alert('❌ Hálózati hiba'); }
}
</script>
</body>
</html>`;
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE };
-43
View File
@@ -1,43 +0,0 @@
// Publish (git commit + push) command construction and result interpretation
// for the Content Editor. Extracted so it is unit-testable in isolation.
//
// WHY the shell shape:
// - `git diff --cached --quiet && echo MARKER || git commit` — commit only when
// staged changes exist; a skipped commit must NOT produce a failing exit code
// (that was the original bug: "nothing added to commit" surfaced as an error).
// - the MARKER echo is the only reliable signal for "no content changes": plain
// output matching ("Already up to date", "Everything up-to-date") also appears
// after REAL publishes (the pull prints it when the remote did not move), which
// used to misclassify genuine publishes as no-ops.
// - `git pull --rebase || (git rebase --abort; false)` — a failed rebase must be
// aborted, otherwise the repo stays mid-rebase and every later publish fails
// with "cannot pull with rebase".
const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
function buildPublishCommand(commitMessage) {
return [
'git add .',
// WHY: logo uploads live in proto/public — 2 levels above the content cwd — so
// stage them too (tolerant: optional path in test throwaway repos, stderr muted).
'(git add ../../public 2>/dev/null || true)',
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
'(git pull --rebase origin main || (git rebase --abort; false))',
'git push origin main',
].join(' && ');
}
function interpretPublishResult(error, stdout, stderr) {
const hadChanges = !stdout.includes(NO_CHANGES_MARKER);
if (error) {
return { ok: false, hadChanges, result: 'error', error: stderr || stdout || error.message };
}
return {
ok: true,
hadChanges,
result: hadChanges ? 'ok' : 'no_changes',
output: hadChanges ? stdout : 'Nincs új változtatás.',
};
}
module.exports = { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult };
-65
View File
@@ -1,65 +0,0 @@
// POST /save handler for the Content Editor — extracted to keep content-editor.js
// under the 400-line hard limit. Returns true when the request was handled.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function handleSaveRoute({
req, res, u, activeFile, files, maxBodyBytes, validate,
writeAudit, backupAndWrite, backupDir, user, clientAddress, cmsDirname,
}) {
if (req.method !== 'POST' || u.pathname !== '/save') return false;
let body = '';
let bodyTooLarge = false;
let bodySize = 0;
req.on('data', c => {
bodySize += c.length;
if (bodySize > maxBodyBytes) { bodyTooLarge = true; return; }
body += c;
});
req.on('end', () => {
try {
if (bodyTooLarge) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${maxBodyBytes} byte)` }));
return;
}
const data = JSON.parse(body);
// Optimistic locking: the editor echoes the fingerprint of the content it
// loaded. If the file changed since (deploy, another tab, git), a blind
// save would silently overwrite those changes — reject with 409 instead.
const clientHash = req.headers['x-content-hash'];
const currentOnDisk = fs.readFileSync(files[activeFile], 'utf8').trim();
const currentHash = crypto.createHash('sha256').update(currentOnDisk).digest('hex');
if (typeof clientHash !== 'string' || clientHash !== currentHash) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'conflict' });
res.writeHead(409, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A tartalom megváltozott, mióta ezt a lapot megnyitottad (pl. deploy vagy másik fül mentett). Frissítsd az oldalt, és végezd el újra a módosításokat.' }));
return;
}
const validation = validate(activeFile, data);
if (!validation.ok) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
return;
}
const backupFile = backupAndWrite(files[activeFile], data, backupDir);
// Return the hash of the written content so the editor tab can refresh its
// fingerprint — otherwise the user's OWN next save would trip the lock.
const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: path.relative(cmsDirname, backupFile), contentHash: newHash }));
} catch (e) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
module.exports = { handleSaveRoute };
-66
View File
@@ -1,66 +0,0 @@
// WHY: Basic Auth has no native logout and its dialog cannot be styled, so a
// successful /login form submit receives a server-side session token in an
// HttpOnly cookie. Basic Auth remains valid in parallel (curl, API use).
const crypto = require('crypto');
const SESSION_COOKIE = 'cms_session';
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
const sessions = new Map(); // token -> expiresAt (ms)
function timingSafeMatch(candidate, expected) {
if (typeof candidate !== 'string' || typeof expected !== 'string') return false;
const cHash = crypto.createHash('sha256').update(candidate).digest();
const eHash = crypto.createHash('sha256').update(expected).digest();
return crypto.timingSafeEqual(cHash, eHash);
}
function validateLogin(user, pass, expectedUser, expectedPass) {
if (!expectedUser || !expectedPass) return false;
const userOk = timingSafeMatch(user, expectedUser);
const passOk = timingSafeMatch(pass, expectedPass);
return Boolean(userOk && passOk);
}
function createSessionCookie(isSecure) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, Date.now() + SESSION_TTL_MS);
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${isSecure ? '; Secure' : ''}`;
}
function clearSessionCookie() {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
function getSessionToken(req) {
const cookies = req.headers.cookie || '';
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-f0-9]+)`));
return match ? match[1] : null;
}
function hasValidSession(req) {
const token = getSessionToken(req);
if (!token) return false;
const expiresAt = sessions.get(token);
if (!expiresAt) return false;
if (Date.now() > expiresAt) {
sessions.delete(token);
return false;
}
return true;
}
function deleteSession(req) {
const token = getSessionToken(req);
if (token) sessions.delete(token);
}
module.exports = {
SESSION_COOKIE,
SESSION_TTL_MS,
timingSafeMatch,
validateLogin,
createSessionCookie,
clearSessionCookie,
hasValidSession,
deleteSession,
};
-112
View File
@@ -1,112 +0,0 @@
// Version history helpers for the CMS: listing automatic backups from
// .content-backups, safe backup-name validation, diff assembly and the
// /versions + /restore route handlers.
const fs = require('fs');
const path = require('path');
const { renderDiffHtml } = require('./cms-diff');
const { backupAndWriteAtomically: backupAndWrite } = require('./cms-core');
// Backup files are named `<fileKey>.<ISO-ish timestamp>.json`
const BACKUP_NAME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/;
// WHY: the backup name arrives as a query parameter — only allow the exact
// `<fileKey>.<timestamp>.json` shape so path traversal (`../`) is impossible.
function safeBackupName(fileKey, candidate) {
if (typeof candidate !== 'string' || !candidate.startsWith(`${fileKey}.`) || !candidate.endsWith('.json')) return null;
const ts = candidate.slice(fileKey.length + 1, -5);
if (!BACKUP_NAME_RE.test(ts)) return null;
return candidate;
}
function formatBackupTimestamp(fileKey, backupName) {
const ts = backupName.slice(fileKey.length + 1, -5);
const m = ts.match(BACKUP_NAME_RE);
if (!m) return ts;
return `${m[1]} ${m[2]}:${m[3]}:${m[4]}`;
}
function listVersions(backupDir, fileKey) {
try {
return fs.readdirSync(backupDir)
.filter(name => safeBackupName(fileKey, name))
.map(name => {
const full = path.join(backupDir, name);
const stat = fs.statSync(full);
return { name, size: stat.size, when: formatBackupTimestamp(fileKey, name) };
})
.sort((a, b) => b.name.localeCompare(a.name)); // newest first
} catch {
return [];
}
}
function readBackupContent(backupDir, backupName) {
return fs.readFileSync(path.join(backupDir, backupName), 'utf8');
}
// Compare a backup with the current file content; returns both pretty texts and
// the rendered diff HTML (backup = old/left, current = new/right).
function buildVersionDiff(backupDir, currentFilePath, fileKey, backupName) {
const backupText = readBackupContent(backupDir, backupName);
const currentText = fs.readFileSync(currentFilePath, 'utf8');
return {
backupName,
when: formatBackupTimestamp(fileKey, backupName),
backupText: backupText.trim(),
currentText: currentText.trim(),
diffHtml: renderDiffHtml(backupText, currentText),
};
}
// WHY: route handling extracted here so content-editor.js stays under the
// 400-line limit. Returns true when the request was handled.
function handleVersionRoutes({ req, res, u, activeFile, backupDir, currentFile, validate, writeAudit, csrfOk, clientAddress, user, versionsPage }) {
if (req.method === 'GET' && u.pathname === '/versions') {
const versions = listVersions(backupDir, activeFile);
let diff = null;
const showRaw = u.searchParams.get('show');
if (showRaw) {
const safe = safeBackupName(activeFile, showRaw);
if (safe) {
try {
diff = buildVersionDiff(backupDir, currentFile, activeFile, safe);
} catch { /* unreadable backup: render list only */ }
}
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(versionsPage(activeFile, diff));
return true;
}
if (req.method === 'POST' && u.pathname === '/restore') {
const backup = safeBackupName(activeFile, u.searchParams.get('backup') || '');
if (!backup) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' }));
return true;
}
try {
const data = JSON.parse(readBackupContent(backupDir, backup));
const validation = validate(activeFile, data);
if (!validation.ok) {
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A mentés nem felel meg a sémának: ' + validation.errors.join('; ') }));
return true;
}
backupAndWrite(currentFile, data, backupDir);
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e) {
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
return true;
}
return false;
}
module.exports = { safeBackupName, listVersions, readBackupContent, buildVersionDiff, formatBackupTimestamp, handleVersionRoutes };
-98
View File
@@ -1,98 +0,0 @@
// WHY: the Content Editor runs on system Node without node_modules, so the user
// guide (docs/felhasznaloi-utmutato.md) is rendered by this small dependency-free
// markdown renderer instead of an external library.
// Supported subset: headings (#..####), bold, inline code, links, ul/ol lists,
// fenced code blocks, horizontal rules, paragraphs. HTML is escaped first.
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderInline(text) {
return escapeHtml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, linkText, url) => {
const safeUrl = /^(https?:\/\/|mailto:|#|\/)/i.test(url) ? url : '#';
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
}
function renderMarkdown(markdown) {
const lines = String(markdown).split('\n');
const out = [];
let listTag = null; // 'ul' | 'ol'
let inCode = false;
const closeList = () => {
if (listTag) {
out.push(`</${listTag}>`);
listTag = null;
}
};
for (const raw of lines) {
const line = raw.trimEnd();
if (line.trim().startsWith('```')) {
closeList();
out.push(inCode ? '</code></pre>' : '<pre><code>');
inCode = !inCode;
continue;
}
if (inCode) {
out.push(escapeHtml(raw));
continue;
}
if (!line.trim()) {
closeList();
continue;
}
const heading = line.match(/^(#{1,4})\s+(.*)$/);
if (heading) {
closeList();
const level = heading[1].length;
out.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
continue;
}
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
closeList();
out.push('<hr>');
continue;
}
const unordered = line.match(/^\s*[-*]\s+(.*)$/);
if (unordered) {
if (listTag !== 'ul') {
closeList();
out.push('<ul>');
listTag = 'ul';
}
out.push(`<li>${renderInline(unordered[1])}</li>`);
continue;
}
const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
if (ordered) {
if (listTag !== 'ol') {
closeList();
out.push('<ol>');
listTag = 'ol';
}
out.push(`<li>${renderInline(ordered[1])}</li>`);
continue;
}
closeList();
out.push(`<p>${renderInline(line)}</p>`);
}
closeList();
if (inCode) out.push('</code></pre>');
return out.join('\n');
}
module.exports = { renderMarkdown, renderInline, escapeHtml };
+7 -12
View File
@@ -30,18 +30,13 @@ run "proto: lint (eslint)" bash -c "cd '${ROOT}/proto' && npm run lint"
# ── Tartalom ─────────────────────────────────────────────────────────────────
run "content: séma-validáció (minden JSON)" node "${ROOT}/scripts/test-content-schema.js"
# ── CMS (content-editor) ─────────────────────────────────────────────────────
run "cms: security guard (auth, CSRF, XFF)" node "${ROOT}/scripts/test-content-editor-security.js"
run "cms: serializer regresszió (collect/reindex)" node "${ROOT}/scripts/test-content-editor-serializer.js"
run "cms: atomikus mentés + backup" node "${ROOT}/scripts/test-content-editor-save.js"
run "cms: optimista zárolás (409, hash-frissítés)" node "${ROOT}/scripts/test-content-editor-conflict.js"
run "cms: verziók panel (diff, restore)" node "${ROOT}/scripts/test-content-editor-versions.js"
run "cms: logó feltöltés (PNG, backup, audit)" node "${ROOT}/scripts/test-content-editor-logo.js"
run "cms: login flow (session, Safari-scenariok)" node "${ROOT}/scripts/test-content-editor-login.js"
run "cms: logout + rate-limit" node "${ROOT}/scripts/test-content-editor-logout.js"
run "cms: guide endpoint + renderer" node "${ROOT}/scripts/test-content-editor-guide.js"
run "cms: alsó sáv layout guard" node "${ROOT}/scripts/test-content-editor-bottombar.js"
run "cms: publish parancs + integráció" node "${ROOT}/scripts/test-cms-publish.js"
# ── CMS ──────────────────────────────────────────────────────────────────────
# WHY nincs itt önálló CMS-tesztblokk: a régi egyedi CMS-t (content-editor.js
# + scripts/cms-*.js + a hozzá tartozó test-content-editor-*.js szkriptek)
# a Payload CMS-re állás (MITHOME-91/92) után leépítettük (MITHOME-93) — a
# Payload admin felület saját, upstream teszteléssel rendelkezik, ezt itt nem
# duplikáljuk. Payload collection/global konfigurációk saját tesztlefedettsége
# külön feladat (MITHOME-96), még nincs implementálva.
# ── Infra ────────────────────────────────────────────────────────────────────
run "plane: sync unit tesztek" node --test "${ROOT}/scripts/plane/plane-sync.test.js"
-180
View File
@@ -1,180 +0,0 @@
#!/usr/bin/env node
/**
* Tests for the CMS publish flow (MITHOME-60):
*
* Unit (scripts/cms-publish.js):
* - command shape: marker echo, conditional commit, rebase-abort fallback
* - result interpretation: marker → no_changes; no marker → ok; error → error
*
* Integration (real server + throwaway git repos):
* 1. publish with no changes → ok, "Nincs új változtatás.", NO deploy
* 2. publish with real changes → ok, commit pushed, deploy ran
* 3. publish with rebase conflict → error reported, rebase aborted (repo not
* left mid-rebase), push never happened, no deploy
*/
const assert = require('assert/strict');
const { execFileSync, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult } = require('../scripts/cms-publish');
const ROOT = path.join(__dirname, '..');
// ── Unit ─────────────────────────────────────────────────────────────────────
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
assert.ok(cmd.startsWith('git add . && (git add ../../public 2>/dev/null || true) && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
assert.ok(cmd.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
assert.ok(cmd.endsWith('git push origin main'), 'push last');
const noChanges = interpretPublishResult(null, `__NO_CONTENT_CHANGES__\nAlready up to date.\nTo ssh://…\n * [new] nothing`, '');
assert.equal(noChanges.ok, true);
assert.equal(noChanges.hadChanges, false);
assert.equal(noChanges.result, 'no_changes');
assert.equal(noChanges.output, 'Nincs új változtatás.');
// Real publish on an unmoved remote: pull prints "Already up to date." AND the
// push line — the old regex misclassified this as no_changes; the marker must win.
const realPublish = interpretPublishResult(null, '[main abc1234] content: frissítve a CMS-ből\n 1 file changed\nAlready up to date.\nTo ssh://git…\n c2cc701..84a4527 main -> main\n', '');
assert.equal(realPublish.ok, true);
assert.equal(realPublish.hadChanges, true);
assert.equal(realPublish.result, 'ok');
const failed = interpretPublishResult(new Error('exit 1'), 'CONFLICT (content): Merge conflict in x\nerror: could not apply…', 'error: Failed to rebase');
assert.equal(failed.ok, false);
assert.equal(failed.result, 'error');
assert.equal(failed.error, 'error: Failed to rebase');
console.log('cms-publish unit tests: OK');
// ── Integration ──────────────────────────────────────────────────────────────
function git(cwd, ...args) {
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-publish-'));
const origin = path.join(tmp, 'origin.git');
const work = path.join(tmp, 'work');
const other = path.join(tmp, 'other');
const deployLog = path.join(tmp, 'deploy.log');
const auditFile = path.join(tmp, 'audit.jsonl');
const PORT = 4127;
const BASE = `http://127.0.0.1:${PORT}`;
execFileSync('git', ['init', '--bare', '-b', 'main', origin]);
execFileSync('git', ['clone', origin, work]);
for (const repo of [work]) git(repo, 'config', 'user.email', 'test@test.hu'), git(repo, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(work, 'home.json'), '{"v": 1}\n');
git(work, 'add', '.');
git(work, 'commit', '-m', 'init');
git(work, 'push', '-u', 'origin', 'main');
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CONTENT_EDITOR_CONTENT_DIR: work,
CONTENT_EDITOR_DEPLOY_CMD: `echo deploy >> ${deployLog}`,
CMS_USER: 'pub-test-user',
CMS_PASS: 'pub-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}/logout`); // rate-limit-free probe
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
async function publish() {
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'pub-test-user', pass: 'pub-test-pass' }),
});
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie } })).text();
const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
const res = await fetch(`${BASE}/publish`, { method: 'POST', headers: { Cookie: cookie, 'X-CSRF-Token': csrf } });
return { status: res.status, body: await res.json() };
}
function deployCount() {
try { return fs.readFileSync(deployLog, 'utf8').trim().split('\n').filter(Boolean).length; } catch { return 0; }
}
// The deploy command runs detached after the response — poll for its effect.
async function waitForDeployCount(expected, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (deployCount() === expected) return;
await new Promise(r => setTimeout(r, 100));
}
assert.equal(deployCount(), expected);
}
async function main() {
await waitForServer();
// 1. no changes → ok, no deploy
const r1 = await publish();
assert.equal(r1.status, 200);
assert.equal(r1.body.ok, true);
assert.equal(r1.body.output, 'Nincs új változtatás.');
assert.equal(deployCount(), 0, 'no-op publish must not deploy');
// 2. real change → ok, pushed, deploy ran
fs.writeFileSync(path.join(work, 'home.json'), '{"v": 2}\n');
const r2 = await publish();
assert.equal(r2.body.ok, true);
await waitForDeployCount(1);
assert.match(git(work, 'log', '-1', '--format=%s'), /content: frissítve a CMS-ből/);
assert.match(git(work, 'status', '--porcelain'), /^$/, 'worktree clean after publish');
// 3. rebase conflict → error, rebase aborted, no push, no deploy
execFileSync('git', ['clone', origin, other]);
for (const args of [['config', 'user.email', 'o@test.hu'], ['config', 'user.name', 'Other']]) git(other, ...args);
fs.writeFileSync(path.join(other, 'home.json'), '{"v": "remote"}\n');
git(other, 'add', '.');
git(other, 'commit', '-m', 'remote edit');
git(other, 'push', 'origin', 'main');
fs.writeFileSync(path.join(work, 'home.json'), '{"v": "local"}\n');
const r3 = await publish();
assert.equal(r3.body.ok, false, 'conflicting publish must report an error');
await new Promise(r => setTimeout(r, 700)); // give a would-be deploy time to (not) appear
assert.equal(deployCount(), 1, 'failed publish must not deploy');
// The local commit exists locally (created before the rebase) but must NOT be pushed.
assert.equal(git(work, 'log', '-1', '--format=%s').trim(), 'content: frissítve a CMS-ből');
const localCommit = git(work, 'rev-parse', 'HEAD');
assert.ok(!git(work, 'ls-remote', origin, 'refs/heads/main').includes(localCommit), 'local conflicted commit must not be pushed');
assert.ok(!git(work, 'status').includes('rebase in progress'), 'rebase must be aborted');
// audit trail classification
const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l));
const publishResults = audit.filter(e => e.event === 'publish_finished').map(e => e.result);
assert.deepEqual(publishResults, ['no_changes', 'ok', 'error']);
console.log('Content Editor publish integration 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 */ }
});
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env node
/**
* Layout guard for the CMS bottom bar (MITHOME-69): the bar items must not
* shift while saving/publishing. Asserts the invariants that keep the layout
* stable:
* - the status slot reserves constant space (flex + visibility, not display)
* - no auto margins redistribute free space between bar items
* - the publish handler locks the button width and restores its label
* - save refreshes the optimistic-lock fingerprint (MITHOME-68)
*/
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const pages = fs.readFileSync(path.join(__dirname, '../scripts/cms-pages.js'), 'utf8');
const client = fs.readFileSync(path.join(__dirname, '../scripts/cms-editor-client.js'), 'utf8');
const statusRule = pages.match(/\.save-status \{[^}]*\}/)[0];
assert.match(statusRule, /flex: 1 1 0/, 'status slot must reserve constant space');
assert.match(statusRule, /visibility: hidden/, 'status must hide via visibility (keeps layout slot)');
assert.doesNotMatch(statusRule, /display: none/, 'display:none would collapse the slot and shift items');
const previewRule = pages.match(/\.preview-link \{[^}]*\}/)[0];
assert.doesNotMatch(previewRule, /margin-left: auto/, 'auto margins redistribute space on width changes');
const logoutRule = pages.match(/\.btn-logout \{[^}]*\}/)[0];
assert.doesNotMatch(logoutRule, /margin-left: auto/, 'auto margins redistribute space on width changes');
assert.match(client, /btn\.style\.minWidth = btn\.offsetWidth \+ 'px'/, 'publish must lock the button width');
assert.match(client, /const originalLabel = btn\.textContent/, 'publish must restore the env-specific label');
assert.match(client, /if \(json\.contentHash\) CONTENT_HASH = json\.contentHash/, 'save must refresh the lock fingerprint');
console.log('Content Editor bottom bar layout guard: OK');
-184
View File
@@ -1,184 +0,0 @@
#!/usr/bin/env node
/**
* Integration test for optimistic locking on CMS save (MITHOME-61):
* 1. save with the correct X-Content-Hash → 200
* 2. save with a stale hash (file changed on disk meanwhile) → 409, file untouched
* 3. save without any hash header → 409 (strict: must always send the fingerprint)
* 4. a follow-up save with the NEW hash succeeds → the editor can continue after refresh
*/
const assert = require('assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 4128;
const BASE = `http://127.0.0.1:${PORT}`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-conflict-'));
const auditFile = path.join(tmp, 'audit.jsonl');
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CMS_USER: 'conflict-test-user',
CMS_PASS: 'conflict-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}/logout`);
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
const hashOf = s => crypto.createHash('sha256').update(s.trim()).digest('hex');
async function call(method, pathName, { body, headers } = {}) {
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'conflict-test-user', pass: 'conflict-test-pass' }),
});
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
if (method === 'GET') {
return fetch(`${BASE}${pathName}`, { headers: { Cookie: cookie, ...headers } });
}
const page = await (await fetch(`${BASE}${pathName.split('?')[0] || '/'}?file=contact`, { headers: { Cookie: cookie } })).text();
const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
return fetch(`${BASE}${pathName}`, { method, headers: { Cookie: cookie, 'X-CSRF-Token': csrf, ...headers }, body });
}
async function main() {
await waitForServer();
const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json');
const original = fs.readFileSync(contentFile, 'utf8');
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'conflict-test-user', pass: 'conflict-test-pass' }),
});
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
const csrfPage = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text();
const csrf = csrfPage.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
// Baseline: read the page and capture the served fingerprint
const page = await (await call('GET', '/?file=contact')).text();
const servedHash = page.match(/let CONTENT_HASH = "([a-f0-9]+)"/)[1];
const diskBefore = fs.readFileSync(contentFile, 'utf8');
assert.equal(servedHash, hashOf(diskBefore), 'served fingerprint matches the file on disk');
const payload = diskBefore; // unchanged content is still a valid save payload
const jsonHeaders = { 'Content-Type': 'application/json' };
// MITHOME-68: the save response returns the new content hash; a tab that
// adopts it can save again — only a genuinely external change may 409.
const v3 = JSON.parse(original);
v3.hero.subtitle = 'Hash frissítés teszt ' + Date.now();
const first = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
},
body: JSON.stringify(v3, null, 2),
});
assert.equal(first.status, 200);
const firstBody = await first.json();
assert.match(firstBody.contentHash, /^[a-f0-9]{64}$/, 'save must return the new content hash');
// same tab continues with the returned hash → 200
const again = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': firstBody.contentHash,
},
body: JSON.stringify(v3, null, 2),
});
assert.equal(again.status, 200, 'save with the refreshed hash must succeed');
// a stale (pre-save) hash still 409s
const stale = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(original),
},
body: JSON.stringify(v3, null, 2),
});
assert.equal(stale.status, 409, 'stale hash must still be rejected');
// leave the disk as the following sections expect it
fs.writeFileSync(contentFile, original);
// 1. correct hash → 200
const ok = await call('POST', '/save?file=contact', {
headers: { ...jsonHeaders, 'X-Content-Hash': servedHash },
body: payload,
});
assert.equal(ok.status, 200);
// 2. stale hash: simulate the file changing on disk (deploy/other tab)
const savedContact = fs.readFileSync(contentFile, 'utf8'); // keep the exact bytes
try {
fs.writeFileSync(contentFile, diskBefore.replace('"responseTime"', '"responseTime" /*changed*/'));
const stale = await call('POST', '/save?file=contact', {
headers: { ...jsonHeaders, 'X-Content-Hash': servedHash },
body: payload,
});
assert.equal(stale.status, 409);
const body = await stale.json();
assert.match(body.error, /megváltozott/);
// file untouched by the rejected save (still the "changed" variant)
assert.ok(fs.readFileSync(contentFile, 'utf8').includes('/*changed*/'));
} finally {
fs.writeFileSync(contentFile, savedContact); // restore
}
// 3. missing hash → 409 (strict)
const noHash = await call('POST', '/save?file=contact', {
headers: jsonHeaders,
body: payload,
});
assert.equal(noHash.status, 409);
// 4. save with the fresh hash of the restored file succeeds
const freshHash = hashOf(fs.readFileSync(contentFile, 'utf8'));
const retry = await call('POST', '/save?file=contact', {
headers: { ...jsonHeaders, 'X-Content-Hash': freshHash },
body: payload,
});
assert.equal(retry.status, 200);
console.log('Content Editor optimistic-lock 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 */ }
});
-113
View File
@@ -1,113 +0,0 @@
#!/usr/bin/env node
/**
* Tests for the CMS user guide:
* 1. markdown renderer unit checks (headings, bold, code, lists, links, escaping)
* 2. /guide endpoint integration — auth-protected, serves the rendered guide
* 3. the main editor page contains the Súgó menu link
*/
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { renderMarkdown } = require('../scripts/markdown-render');
// ── 1. Markdown renderer ─────────────────────────────────────────────────────
const rendered = renderMarkdown([
'# Cím',
'',
'Ez **félkövér** és `kód`, valamint [link](https://example.com).',
'',
'- első',
'- második',
'',
'1. lépés',
'2. lépés',
'',
'---',
'',
'<script>alert(1)</script>',
].join('\n'));
assert.match(rendered, /<h1>Cím<\/h1>/);
assert.match(rendered, /<strong>félkövér<\/strong>/);
assert.match(rendered, /<code>kód<\/code>/);
assert.match(rendered, /<a href="https:\/\/example\.com"[^>]*>link<\/a>/);
assert.match(rendered, /<ul>\s*<li>első<\/li>\s*<li>második<\/li>\s*<\/ul>/);
assert.match(rendered, /<ol>\s*<li>lépés<\/li>\s*<li>lépés<\/li>\s*<\/ol>/);
assert.match(rendered, /<hr>/);
// Raw HTML must be escaped, never executable
assert.doesNotMatch(rendered, /<script>alert/);
assert.match(rendered, /&lt;script&gt;/);
console.log('Markdown renderer unit tests: OK');
// ── 2. /guide endpoint + Súgó menu link (real server) ───────────────────────
const PORT = 4124;
const BASE = `http://127.0.0.1:${PORT}`;
const ROOT = path.join(__dirname, '..');
const AUDIT_FILE = path.join(os.tmpdir(), `content-editor-audit-guide-${process.pid}.jsonl`);
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: AUDIT_FILE,
CMS_USER: 'guide-test-user',
CMS_PASS: 'guide-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}/logout`); // rate-limit-free readiness probe
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
async function main() {
await waitForServer();
const auth = 'Basic ' + Buffer.from('guide-test-user:guide-test-pass').toString('base64');
// /guide requires authentication
const unauth = await fetch(`${BASE}/guide`);
assert.equal(unauth.status, 401);
// /guide serves the rendered markdown as HTML
const guide = await fetch(`${BASE}/guide`, { headers: { Authorization: auth } });
assert.equal(guide.status, 200);
assert.match(guide.headers.get('content-type') || '', /text\/html/);
const guideHtml = await guide.text();
assert.match(guideHtml, /Felhasználói útmutató/);
assert.match(guideHtml, /<h2[^>]*>.*Content Editor/); // rendered from the markdown source
// The guide file must exist in the repo (maintenance contract)
assert.ok(fs.existsSync(path.join(ROOT, 'docs', 'felhasznaloi-utmutato.md')));
// The editor page exposes the Súgó menu entry
const editor = await fetch(`${BASE}/`, { headers: { Authorization: auth } });
const editorHtml = await editor.text();
assert.match(editorHtml, /href="\/guide"[^>]*>❓ Súgó/);
console.log('Content Editor guide endpoint test: OK');
}
main()
.catch(err => { console.error('❌', err.message); process.exitCode = 1; })
.finally(() => {
child.kill('SIGTERM');
try { fs.unlinkSync(AUDIT_FILE); } catch { /* already gone */ }
});
-169
View File
@@ -1,169 +0,0 @@
#!/usr/bin/env node
/**
* Integration test for the CMS login flow (MITHOME-58):
* 1. GET /login is public and serves the styled login page with the logo
* 2. GET /logo.png is public
* 3. POST /login with wrong credentials → 401; with correct ones → 200 + session cookie
* 4. The session cookie authenticates GET / (200) where no Basic credentials exist
* 5. POST /logout (cookie + CSRF) invalidates the session; GET / with the dead
* cookie now redirects to /login for browser navigations
* 6. Non-browser requests without credentials still get the 401 challenge
* 7. Failed form logins count toward the auth rate limiter (6th → 429)
*/
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
function startServer(port) {
const auditFile = path.join(os.tmpdir(), `content-editor-audit-login-${port}-${process.pid}.jsonl`);
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(port),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CMS_USER: 'login-test-user',
CMS_PASS: 'login-test-pass',
CMS_DEPLOY_ENV: 'staging',
},
stdio: 'ignore',
});
return { child, auditFile };
}
async function waitForServer(base, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(`${base}/logout`); // rate-limit-free readiness probe (GET)
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
async function main() {
// ── Happy path server ──────────────────────────────────────────────────────
const PORT = 4125;
const BASE = `http://127.0.0.1:${PORT}`;
const s1 = startServer(PORT);
try {
await waitForServer(BASE);
// 1. login page is public
const page = await fetch(`${BASE}/login`);
assert.equal(page.status, 200);
const pageHtml = await page.text();
assert.match(pageHtml, /mozdIT CMS — Belépés/);
assert.match(pageHtml, /\/logo\.png/);
// 2. logo is public
const logo = await fetch(`${BASE}/logo.png`);
assert.equal(logo.status, 200);
assert.match(logo.headers.get('content-type') || '', /image\/png/);
// 3a. wrong credentials
const bad = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'login-test-user', pass: 'wrong' }),
});
assert.equal(bad.status, 401);
// 3b. correct credentials → session cookie
const good = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'login-test-user', pass: 'login-test-pass' }),
});
assert.equal(good.status, 200);
assert.deepEqual(await good.json(), { ok: true });
const setCookie = good.headers.get('set-cookie') || '';
assert.match(setCookie, /cms_session=[a-f0-9]+/);
assert.match(setCookie, /HttpOnly/);
assert.match(setCookie, /SameSite=Strict/);
// HTTP test run (no x-forwarded-proto) must NOT set Secure, or the cookie would be unusable
assert.doesNotMatch(setCookie, /Secure/);
const sessionCookie = setCookie.split(';')[0];
// 4. session cookie authenticates without Basic credentials
const authed = await fetch(`${BASE}/`, { headers: { Cookie: sessionCookie } });
assert.equal(authed.status, 200);
// Extract the CSRF token from the served editor page for the logout POST
const editorHtml = await authed.text();
const csrf = editorHtml.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
// 5. POST /logout kills the session
const logout = await fetch(`${BASE}/logout`, {
method: 'POST',
headers: { Cookie: sessionCookie, 'X-CSRF-Token': csrf },
});
assert.equal(logout.status, 200);
// Dead cookie + browser navigation → redirect to /login
const redirected = await fetch(`${BASE}/`, {
headers: { Cookie: sessionCookie, Accept: 'text/html,application/xhtml+xml' },
redirect: 'manual',
});
assert.equal(redirected.status, 302);
assert.equal(redirected.headers.get('location'), '/login');
// 6. non-browser requests get a plain 401 WITHOUT a Basic challenge
// (Safari pops its native auth dialog on challenged fetch calls).
const apiStyle = await fetch(`${BASE}/`);
assert.equal(apiStyle.status, 401);
assert.equal(apiStyle.headers.get('www-authenticate'), null);
// 7. Safari scenario: browser navigation with CACHED Basic credentials but no
// session must still land on /login — otherwise logout would be ineffective
// in browsers that resend Basic auth automatically.
const basic = 'Basic ' + Buffer.from('login-test-user:login-test-pass').toString('base64');
const safariLike = await fetch(`${BASE}/`, {
headers: { Authorization: basic, Accept: 'text/html,application/xhtml+xml' },
redirect: 'manual',
});
assert.equal(safariLike.status, 302);
assert.equal(safariLike.headers.get('location'), '/login');
// 8. the same credentials DO authenticate a non-browser request (curl/API)
const curlLike = await fetch(`${BASE}/`, { headers: { Authorization: basic } });
assert.equal(curlLike.status, 200);
console.log('Content Editor login flow test: OK');
} finally {
s1.child.kill('SIGTERM');
try { fs.unlinkSync(s1.auditFile); } catch { /* already gone */ }
}
// ── Rate-limit server (fresh limiter state) ────────────────────────────────
const PORT2 = 4126;
const BASE2 = `http://127.0.0.1:${PORT2}`;
const s2 = startServer(PORT2);
try {
await waitForServer(BASE2);
const attempt = () => fetch(`${BASE2}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'login-test-user', pass: 'wrong' }),
});
for (let i = 0; i < 5; i++) {
assert.equal((await attempt()).status, 401);
}
const limited = await attempt();
assert.equal(limited.status, 429);
console.log('Content Editor login rate-limit test: OK');
} finally {
s2.child.kill('SIGTERM');
try { fs.unlinkSync(s2.auditFile); } catch { /* already gone */ }
}
}
main().catch(err => { console.error('❌', err.message); process.exitCode = 1; });
-189
View File
@@ -1,189 +0,0 @@
#!/usr/bin/env node
/**
* Integration test for CMS logo upload (MITHOME-65):
* 1. GET /branding serves the logo page (session-auth)
* 2. POST /logo?target=icon with a valid PNG replaces the file, backs up the
* old one into .content-backups and audits logo_updated
* 3. non-PNG bytes → 415; >1 MiB → 413; bad target → 400; no CSRF → 403;
* unauthenticated → 401
* Original logo files are restored at the end.
*/
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 4132;
const BASE = `http://127.0.0.1:${PORT}`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-logo-'));
const auditFile = path.join(tmp, 'audit.jsonl');
// Minimal valid 1x1 transparent PNG
const TINY_PNG = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da636460f8ff9f0001040100c9fe92ef0000000049454e44ae426082',
'hex'
);
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CMS_USER: 'logo-test-user',
CMS_PASS: 'logo-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();
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'logo-test-user', pass: 'logo-test-pass' }),
});
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie } })).text();
const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
const iconPath = path.join(ROOT, 'proto', 'public', 'mozdit_logo.png');
const headerPath = path.join(ROOT, 'proto', 'public', 'mozdit_logo_text.png');
const originalIcon = fs.readFileSync(iconPath);
const originalHeader = fs.readFileSync(headerPath);
const backupDir = path.join(ROOT, '.content-backups');
try {
// 1. branding page
const branding = await fetch(`${BASE}/branding`, { headers: { Cookie: cookie } });
assert.equal(branding.status, 200);
assert.match(await branding.text(), /Logó kezelése/);
// 2. valid upload replaces the file and creates a backup
const before = fs.readdirSync(backupDir).filter(n => n.startsWith('mozdit_logo.png.'));
const up = await fetch(`${BASE}/logo?target=icon`, {
method: 'POST',
headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
body: TINY_PNG,
});
assert.equal(up.status, 200);
const upBody = await up.json();
assert.equal(upBody.ok, true);
assert.match(upBody.backup, /^mozdit_logo\.png\./);
assert.deepEqual(fs.readFileSync(iconPath), TINY_PNG, 'icon file replaced');
const after = fs.readdirSync(backupDir).filter(n => n.startsWith('mozdit_logo.png.'));
assert.equal(after.length, before.length + 1, 'old logo backed up');
// audit entry
const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l));
assert.ok(audit.some(e => e.event === 'logo_updated' && e.result === 'ok'));
// variant preview route serves the header logo
const headerPreview = await fetch(`${BASE}/logo.png?variant=header`);
assert.equal(headerPreview.status, 200);
assert.deepEqual(Buffer.from(await headerPreview.arrayBuffer()), originalHeader);
// 3a. non-PNG → 415
const bad = await fetch(`${BASE}/logo?target=icon`, {
method: 'POST',
headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
body: Buffer.from('definitely not a png'),
});
assert.equal(bad.status, 415);
// 3b. oversized → 413
const big = Buffer.alloc(1024 * 1024 + 1);
big.set(TINY_PNG.subarray(0, 8));
const tooBig = await fetch(`${BASE}/logo?target=icon`, {
method: 'POST',
headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
body: big,
});
assert.equal(tooBig.status, 413);
// 3c. bad target → 400
const badTarget = await fetch(`${BASE}/logo?target=../../etc`,
{ method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG });
assert.equal(badTarget.status, 400);
// 3d. authenticated but no CSRF → 403
const noCsrf = await fetch(`${BASE}/logo?target=icon`,
{ method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png' }, body: TINY_PNG });
assert.equal(noCsrf.status, 403);
// 3e. unauthenticated (valid CSRF token but no session) → 401
const anon = await fetch(`${BASE}/logo?target=icon`,
{ method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG });
assert.equal(anon.status, 401);
// 4. partner logo upload (MITHOME-83)
const partnerDir = path.join(ROOT, 'proto', 'public', 'partners');
// Unique names so the test never collides with real partner logos.
const runId = process.pid + '-' + Date.now();
const created = [];
try {
const up = await fetch(`${BASE}/partner-logo?name=acme-${runId}`, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie },
body: TINY_PNG,
});
assert.equal(up.status, 200);
const body = await up.json();
assert.equal(body.ok, true);
assert.equal(body.path, `/partners/acme-${runId}.png`);
created.push(path.join(partnerDir, `acme-${runId}.png`));
assert.ok(fs.existsSync(created[0]), 'partner logo file created');
// non-PNG → 415
const bad = await fetch(`${BASE}/partner-logo?name=x-${runId}`, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie },
body: Buffer.from('not a png'),
});
assert.equal(bad.status, 415);
// traversal name is sanitized (no path escape)
const trav = await fetch(`${BASE}/partner-logo?name=../evil-${runId}`, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie },
body: TINY_PNG,
});
assert.equal(trav.status, 200);
const tBody = await trav.json();
assert.ok(tBody.path.startsWith('/partners/'), 'traversal name is sanitized to a safe slug');
assert.ok(!tBody.path.includes('..'), 'no traversal in the returned path');
created.push(path.join(ROOT, 'proto', 'public', tBody.path));
} finally {
for (const f of created) { try { fs.unlinkSync(f); } catch { /* noop */ } }
try { fs.rmdirSync(partnerDir); } catch { /* only removes when empty */ }
}
console.log('Content Editor logo upload test: OK');
} finally {
fs.writeFileSync(iconPath, originalIcon);
fs.writeFileSync(headerPath, originalHeader);
}
}
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 */ }
});
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env node
/**
* Integration test for the Content Editor /logout endpoint.
* Spawns the real server on an ephemeral port and verifies:
* 1. /logout always answers 401 + WWW-Authenticate (invalidates cached Basic Auth)
* 2. /logout is exempt from the auth rate limiter (logging out never locks the user out)
* 3. the auth rate limiter still works for real failed logins (429 after the limit)
*/
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const PORT = 4123;
const BASE = `http://127.0.0.1:${PORT}`;
const ROOT = path.join(__dirname, '..');
const AUDIT_FILE = path.join(os.tmpdir(), `content-editor-audit-test-${process.pid}.jsonl`);
const env = {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: AUDIT_FILE,
CMS_USER: 'logout-test-user',
CMS_PASS: 'logout-test-pass',
CMS_DEPLOY_ENV: 'staging',
};
const child = spawn('node', ['content-editor.js'], { cwd: ROOT, env, stdio: 'ignore' });
async function waitForServer(timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
// Poll /logout (rate-limit-free) so the readiness probe itself never
// consumes a failed-login attempt from the auth rate limiter.
await fetch(`${BASE}/logout`);
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
async function main() {
await waitForServer();
// 1. /logout answers 401 without a Basic challenge (Safari would pop its
// native auth dialog on challenged fetch calls)
const logoutRes = await fetch(`${BASE}/logout`);
assert.equal(logoutRes.status, 401);
assert.equal(logoutRes.headers.get('www-authenticate'), null);
// 2. /logout is exempt from the auth rate limiter: many logout calls must not
// consume the failed-login budget.
for (let i = 0; i < 10; i++) {
const res = await fetch(`${BASE}/logout`);
assert.equal(res.status, 401);
}
// A failed real login right after the logout flood must still be 401, not 429.
const failed = await fetch(`${BASE}/`, {
headers: { 'Authorization': 'Basic ' + Buffer.from('logout-test-user:wrong').toString('base64') },
});
assert.equal(failed.status, 401);
// 3. The limiter still engages after repeated real failures (5 allowed, 6th → 429)
for (let i = 0; i < 4; i++) {
const res = await fetch(`${BASE}/`, {
headers: { 'Authorization': 'Basic ' + Buffer.from('logout-test-user:wrong').toString('base64') },
});
assert.equal(res.status, 401);
}
const limited = await fetch(`${BASE}/`, {
headers: { 'Authorization': 'Basic ' + Buffer.from('logout-test-user:wrong').toString('base64') },
});
assert.equal(limited.status, 429);
// Valid credentials would now also be throttled — that is expected limiter behavior.
console.log('Content Editor logout endpoint test: OK');
}
main()
.catch(err => { console.error('❌', err.message); process.exitCode = 1; })
.finally(() => {
child.kill('SIGTERM');
try { fs.unlinkSync(AUDIT_FILE); } catch { /* already gone */ }
});
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env node
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { backupAndWriteAtomically } = require('../content-editor');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mozdit-content-save-'));
try {
const target = path.join(tempDir, 'home.json');
const backupDir = path.join(tempDir, 'backups');
const original = { title: 'Eredeti', enabled: true };
const replacement = { title: 'Új', enabled: false };
fs.writeFileSync(target, JSON.stringify(original) + '\n');
const backupFile = backupAndWriteAtomically(target, replacement, backupDir);
assert.deepEqual(JSON.parse(fs.readFileSync(target, 'utf8')), replacement);
assert.deepEqual(JSON.parse(fs.readFileSync(backupFile, 'utf8')), original);
assert.equal(fs.readdirSync(tempDir).some(name => name.endsWith('.tmp')), false);
console.log('Content Editor atomic save test: OK');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env node
const assert = require('assert/strict');
process.env.CMS_USER = 'test-editor';
process.env.CMS_PASS = 'not-a-real-secret';
process.env.CMS_DEPLOY_ENV = 'staging';
const {
hasValidCredentials,
hasValidCsrfToken,
getClientAddress,
securityConfigIsValid,
csrfToken,
} = require('../content-editor');
const authorization = Buffer.from('test-editor:not-a-real-secret').toString('base64');
assert.equal(securityConfigIsValid(), true);
assert.equal(hasValidCredentials({ headers: { authorization: `Basic ${authorization}` } }), true);
assert.equal(hasValidCredentials({ headers: { authorization: 'Basic invalid' } }), false);
assert.equal(hasValidCredentials({ headers: {} }), false);
assert.equal(hasValidCsrfToken({ headers: { 'x-csrf-token': csrfToken } }), true);
assert.equal(hasValidCsrfToken({ headers: { 'x-csrf-token': 'invalid-token' } }), false);
assert.equal(hasValidCsrfToken({ headers: {} }), false);
// X-Forwarded-For: the appended (last) entry is the proxy-observed client address;
// a leading spoofed entry must not become the rate-limit key.
assert.equal(getClientAddress({ headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }, socket: {} }), '5.6.7.8');
assert.equal(getClientAddress({ headers: { 'x-forwarded-for': 'spoofed, , 9.9.9.9' }, socket: {} }), '9.9.9.9');
assert.equal(getClientAddress({ headers: { 'x-forwarded-for': '5.6.7.8' }, socket: {} }), '5.6.7.8');
assert.equal(getClientAddress({ headers: {}, socket: { remoteAddress: '127.0.0.1' } }), '127.0.0.1');
assert.equal(getClientAddress({ headers: { 'x-forwarded-for': ' ' }, socket: {} }), 'unknown');
console.log('Content Editor security guard test: OK');
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env node
/**
* Regressziós teszt a Content Editor böngészőben futó serializeréhez.
* A szerver által generált tényleges JavaScriptet futtatja minimális DOM-mal,
* így a szerkesztő és a teszt nem két eltérő implementációt vizsgál.
*/
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { createRequire } = require('module');
const source = fs.readFileSync('content-editor.js', 'utf8')
.replace('if (require.main === module) {', 'globalThis.renderContentEditor = HTML;\nif (false) {');
const editorRequire = createRequire(path.join(process.cwd(), 'content-editor.js'));
const serverContext = { require: editorRequire, console, process, Buffer, module: { exports: {} }, __dirname: process.cwd(), globalThis: {} };
vm.createContext(serverContext);
new vm.Script(source).runInContext(serverContext);
const fixture = {
title: 'Teszt',
enabled: true,
limit: 42,
sections: [{ id: 'first', items: ['egy', 'kettő'], settings: { visible: false, weight: 1 } }],
};
const clientJs = fs.readFileSync('scripts/cms-editor-client.js', 'utf8');
const html = serverContext.globalThis.renderContentEditor('home', JSON.stringify(fixture), null, 'csrf-test-token', { common: '⚙️ Közös' }, clientJs, 'hash-test-value');
assert.ok(html.includes('let CONTENT_HASH = "hash-test-value"'));
const browserSource = [...html.matchAll(/<script(?: [^>]*)?>([\s\S]*?)<\/script>/g)].at(-1)[1]
.replace("render(DATA, document.getElementById('editor'));", '')
.replace("const toast = document.querySelector('.toast');", 'const toast = null;');
const fields = [
{ dataset: { path: 'title', type: 'string' }, value: 'Módosított' },
{ dataset: { path: 'enabled', type: 'boolean' }, checked: true },
{ dataset: { path: 'limit', type: 'number' }, value: '99' },
{ dataset: { path: 'sections[0].id', type: 'string' }, value: 'first' },
{ dataset: { path: 'sections[0].items[0]', type: 'string' }, value: 'egy' },
{ dataset: { path: 'sections[0].items[1]', type: 'string' }, value: 'kettő' },
{ dataset: { path: 'sections[0].settings.visible', type: 'boolean' }, checked: false },
{ dataset: { path: 'sections[0].settings.weight', type: 'number' }, value: '3' },
];
const document = {
getElementById: id => id === 'page-data' ? { textContent: JSON.stringify(fixture) } : {},
querySelectorAll: selector => selector === '[data-path]' ? fields : [],
querySelector: () => null,
// Keyboard-shortcut binding in the client script — not under test here.
addEventListener: () => {},
};
const browserContext = { document, console, setTimeout, fetch: async () => ({ json: async () => ({ ok: true }) }) };
vm.createContext(browserContext);
new vm.Script(`${browserSource}\nglobalThis.__collect = collect; globalThis.__reindexItems = reindexItems;`).runInContext(browserContext);
assert.deepStrictEqual(JSON.parse(JSON.stringify(browserContext.__collect())), {
title: 'Módosított',
enabled: true,
limit: 99,
sections: [{ id: 'first', items: ['egy', 'kettő'], settings: { visible: false, weight: 3 } }],
});
// Regression (MITHOME-30): reindexing a NESTED string array (e.g. services[1].specs.items
// after deleting its first item) must rewrite only the item's own trailing index.
// The old "replace first [n]" logic rewrote the OUTER array index and scattered the
// paths across services[0..n], producing sparse arrays and validation errors like
// "$.details.services[1].specs.items[0]: string érték szükséges".
const nestedEls = [1, 2, 3, 4, 5, 6].map(n => ({ dataset: { path: `details.services[1].specs.items[${n}]` } }));
const nestedItems = {
dataset: { arrayItems: 'details.services[1].specs.items' },
children: nestedEls.map(el => ({
querySelectorAll: selector => (selector === '[data-path]' ? [el] : []),
querySelector: () => null,
})),
};
browserContext.__reindexItems(nestedItems);
assert.deepEqual(nestedEls.map(el => el.dataset.path), [
'details.services[1].specs.items[0]',
'details.services[1].specs.items[1]',
'details.services[1].specs.items[2]',
'details.services[1].specs.items[3]',
'details.services[1].specs.items[4]',
'details.services[1].specs.items[5]',
]);
// Reindexing the OUTER services array rewrites only the outer index and keeps
// nested field paths (including nested array item indices) intact.
const cardEls = [
{ dataset: { path: 'details.services[2].icon' } },
{ dataset: { path: 'details.services[2].specs.title' } },
{ dataset: { path: 'details.services[2].specs.items[4]' } },
];
const firstCardEl = { dataset: { path: 'details.services[0].icon' } };
const servicesItems = {
dataset: { arrayItems: 'details.services' },
children: [
{ querySelectorAll: selector => (selector === '[data-path]' ? [firstCardEl] : []), querySelector: () => null },
{ querySelectorAll: selector => (selector === '[data-path]' ? cardEls : []), querySelector: () => null },
],
};
browserContext.__reindexItems(servicesItems);
assert.equal(firstCardEl.dataset.path, 'details.services[0].icon');
assert.deepEqual(cardEls.map(el => el.dataset.path), [
'details.services[1].icon',
'details.services[1].specs.title',
'details.services[1].specs.items[4]',
]);
console.log('Content Editor serializer regression test: OK');
-83
View File
@@ -1,83 +0,0 @@
#!/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 */ }
});
-174
View File
@@ -1,174 +0,0 @@
#!/usr/bin/env node
/**
* Integration test for the CMS Versions panel (MITHOME-64):
* 1. GET /versions lists the backups of the file (auth required)
* 2. GET /versions?show=<backup> renders a diff vs the current content
* 3. POST /restore restores an older backup; the pre-restore state gets a
* fresh backup too (restore is reversible)
* 4. path traversal backup names are rejected (400)
* 5. restore without CSRF is rejected (403)
*/
const assert = require('assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 4131;
const BASE = `http://127.0.0.1:${PORT}`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-versions-'));
const auditFile = path.join(tmp, 'audit.jsonl');
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CMS_USER: 'versions-test-user',
CMS_PASS: 'versions-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 session() {
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'versions-test-user', pass: 'versions-test-pass' }),
});
return (login.headers.get('set-cookie') || '').split(';')[0];
}
async function csrfOf(cookie) {
const page = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text();
return page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
}
const hashOf = s => crypto.createHash('sha256').update(s.trim()).digest('hex');
async function main() {
await waitForServer();
const cookie = await session();
const csrf = await csrfOf(cookie);
const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json');
const original = fs.readFileSync(contentFile, 'utf8');
const backupDir = path.join(ROOT, '.content-backups');
const testStartedAt = Date.now();
try {
// Create two saves → two backups of intermediate states
const runId = Date.now();
const v1 = JSON.parse(original);
v1.hero.subtitle = 'Verzió teszt #1 ' + runId;
const v2 = JSON.parse(original);
v2.hero.subtitle = 'Verzió teszt #2 ' + runId;
for (const variant of [v1, v2]) {
const res = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
},
body: JSON.stringify(variant, null, 2),
});
assert.equal(res.status, 200, 'seed save must succeed');
}
// restore the pristine original as the "current" state for the diff assertion
const third = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
},
body: original,
});
assert.equal(third.status, 200);
// 1. versions page lists backups (names appear in the show= comparison links)
const versionsPage = await (await fetch(`${BASE}/versions?file=contact`, { headers: { Cookie: cookie } })).text();
assert.match(versionsPage, /Verziók/);
assert.match(versionsPage, /Visszaállítás/);
const names = [...versionsPage.matchAll(/restore\('([^']+)'\)/g)].map(m => m[1]);
assert.ok(names.length >= 3, `expected at least 3 backups, got ${names.length}`);
// backups of v1 (the oldest seeded state) — pick the one that contains subtitle #1
// (backups hold the state BEFORE each save: original, v1, v2)
// 2. diff view: pick the backup that holds "Verzió teszt #1" (created during
// this run) and compare it with the current (original) content
const backupHoldingV1 = fs.readdirSync(backupDir)
.filter(name => name.startsWith('contact.'))
.filter(name => fs.statSync(path.join(backupDir, name)).mtimeMs >= testStartedAt)
.find(name => fs.readFileSync(path.join(backupDir, name), 'utf8').includes('Verzió teszt #1 ' + runId));
assert.ok(backupHoldingV1, 'seeded backup holding v1 must exist');
const diffPage = await (await fetch(`${BASE}/versions?file=contact&show=${backupHoldingV1}`, { headers: { Cookie: cookie } })).text();
assert.match(diffPage, /diff-del/, 'diff must contain removed lines (backup side)');
assert.match(diffPage, /diff-add/, 'diff must contain added lines (current side)');
assert.match(diffPage, /Verzió teszt #1/);
// 3. restore the v1 backup → file content becomes v1
const restore = await fetch(`${BASE}/restore?file=contact&backup=${backupHoldingV1}`, {
method: 'POST',
headers: { Cookie: cookie, 'X-CSRF-Token': csrf },
});
assert.equal(restore.status, 200);
assert.ok(fs.readFileSync(contentFile, 'utf8').includes('Verzió teszt #1 ' + runId));
// restore created a new backup of the pre-restore state (reversibility)
const afterPage = await (await fetch(`${BASE}/versions?file=contact`, { headers: { Cookie: cookie } })).text();
const namesAfter = [...afterPage.matchAll(/restore\('([^']+)'\)/g)].map(m => m[1]);
assert.equal(namesAfter.length, names.length + 1, 'restore must back up the current state first');
// 4. traversal is rejected
const evil = await fetch(`${BASE}/restore?file=contact&backup=${encodeURIComponent('../../package.json')}`, {
method: 'POST',
headers: { Cookie: cookie, 'X-CSRF-Token': csrf },
});
assert.equal(evil.status, 400);
// 5. no CSRF → 403
const noCsrf = await fetch(`${BASE}/restore?file=contact&backup=${backupHoldingV1}`, {
method: 'POST',
headers: { Cookie: cookie },
});
assert.equal(noCsrf.status, 403);
// unauthenticated listing is redirected for browsers / 401 otherwise
const anon = await fetch(`${BASE}/versions?file=contact`);
assert.equal(anon.status, 401);
console.log('Content Editor versions panel test: OK');
} finally {
fs.writeFileSync(contentFile, original); // leave the repo pristine
}
}
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 */ }
});