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
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:
co-authored by
Claude Sonnet 5
parent
21a0d73639
commit
12b2711168
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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) || ' '}</div>`).join('\n');
|
||||
}
|
||||
|
||||
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Boot
|
||||
render(DATA, document.getElementById('editor'));
|
||||
|
||||
// Auto-dismiss toast
|
||||
const toast = document.querySelector('.toast');
|
||||
if (toast) setTimeout(() => toast.remove(), 3500);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -1,325 +0,0 @@
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
// 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */ }
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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 */ }
|
||||
});
|
||||
@@ -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, /<script>/);
|
||||
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 */ }
|
||||
});
|
||||
@@ -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; });
|
||||
@@ -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 */ }
|
||||
});
|
||||
@@ -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 */ }
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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 */ }
|
||||
});
|
||||
@@ -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 */ }
|
||||
});
|
||||
Reference in New Issue
Block a user