fix(cms): implement v2 security and stability review findings
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Resolves: - CSRF false positive checked (global POST protection) - Publish mutex to prevent git lock / double deploy - Basic Auth rate limit checked before credential evaluation - Memory leak in rate limiter (added GC interval) - XSS in Toast messages - XSS in data-path attribute - CI healthcheck port mismatch (3000 -> 8080) - Added security headers (X-Frame-Options, X-Content-Type-Options)
This commit is contained in:
@@ -98,7 +98,7 @@ jobs:
|
|||||||
- name: ⏳ Wait for services to be ready
|
- name: ⏳ Wait for services to be ready
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for services to start..."
|
echo "Waiting for services to start..."
|
||||||
timeout 120 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done'
|
timeout 120 bash -c 'until curl -f http://localhost:8080/api/health; do sleep 2; done'
|
||||||
|
|
||||||
- name: 🧪 Run integration tests
|
- name: 🧪 Run integration tests
|
||||||
working-directory: ./proto
|
working-directory: ./proto
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Docker Compose-szal deployolv
|
|||||||
| MITHOME-73 | Lábléc/Kapcsolat cím CMS-szerkeszthetővé tétele (common.json footer.address) | ✅ |
|
| MITHOME-73 | Lábléc/Kapcsolat cím CMS-szerkeszthetővé tétele (common.json footer.address) | ✅ |
|
||||||
| MITHOME-74 | CMS publish deploy: hiányzó cwd — a deploy a gyökérben landolt | ✅ |
|
| MITHOME-74 | CMS publish deploy: hiányzó cwd — a deploy a gyökérben landolt | ✅ |
|
||||||
| MITHOME-75 | CMS: gyorsbillentyűk (Ctrl+S Mentés, Ctrl+P Publikálás, Ctrl+Shift+V Verziók, ? súgó) | ✅ |
|
| MITHOME-75 | CMS: gyorsbillentyűk (Ctrl+S Mentés, Ctrl+P Publikálás, Ctrl+Shift+V Verziók, ? súgó) | ✅ |
|
||||||
|
| MITHOME-76 | CMS: publish public path javítása, brute-force & timing attack védelem, Docker localhost bind | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -30,6 +30,7 @@ const GUIDE_FILE = process.env.CONTENT_EDITOR_GUIDE_FILE || path.join(__dirname,
|
|||||||
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const AUTH_MAX_ATTEMPTS = 5;
|
const AUTH_MAX_ATTEMPTS = 5;
|
||||||
const PUBLISH_MAX_ATTEMPTS = 3;
|
const PUBLISH_MAX_ATTEMPTS = 3;
|
||||||
|
let isPublishing = false;
|
||||||
|
|
||||||
const FILES = {
|
const FILES = {
|
||||||
common: path.join(CONTENT_DIR, 'common.json'),
|
common: path.join(CONTENT_DIR, 'common.json'),
|
||||||
@@ -65,6 +66,8 @@ const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-cli
|
|||||||
const core = require('./scripts/cms-core');
|
const core = require('./scripts/cms-core');
|
||||||
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = 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 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 hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
|
||||||
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
|
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
|
||||||
const isBrowserNavigation = core.isBrowserNavigation;
|
const isBrowserNavigation = core.isBrowserNavigation;
|
||||||
@@ -82,6 +85,8 @@ function readDeployVersion() {
|
|||||||
const DEPLOY_VERSION = readDeployVersion();
|
const DEPLOY_VERSION = readDeployVersion();
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
const clientAddress = getClientAddress(req);
|
const clientAddress = getClientAddress(req);
|
||||||
if (!securityConfigIsValid()) {
|
if (!securityConfigIsValid()) {
|
||||||
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
|
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
@@ -141,6 +146,12 @@ const server = http.createServer(async (req, res) => {
|
|||||||
body += c;
|
body += c;
|
||||||
});
|
});
|
||||||
req.on('end', () => {
|
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 user = '';
|
||||||
let pass = '';
|
let pass = '';
|
||||||
try {
|
try {
|
||||||
@@ -157,12 +168,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
res.end(JSON.stringify({ ok: true }));
|
res.end(JSON.stringify({ ok: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
|
recordRateLimitAttempt(`auth:${clientAddress}`);
|
||||||
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;
|
|
||||||
}
|
|
||||||
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
|
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
|
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
|
||||||
@@ -170,14 +176,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
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)) {
|
if (!isAuthenticated(req)) {
|
||||||
const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS);
|
recordRateLimitAttempt(`auth:${clientAddress}`);
|
||||||
writeAudit('authentication_failed', { clientAddress, limited });
|
writeAudit('authentication_failed', { clientAddress, limited: false });
|
||||||
if (limited) {
|
|
||||||
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
|
|
||||||
res.end('Too many authentication attempts');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Browser navigations land on the styled login page; API/curl gets a plain 401.
|
// 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
|
// WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls
|
||||||
// that receive a Basic challenge — the styled /login page handles browsers.
|
// that receive a Basic challenge — the styled /login page handles browsers.
|
||||||
@@ -283,7 +290,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
// POST /publish — Git Commit, Pull Rebase & Push
|
// POST /publish — Git Commit, Pull Rebase & Push
|
||||||
if (req.method === 'POST' && u.pathname === '/publish') {
|
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)) {
|
if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) {
|
||||||
|
isPublishing = false;
|
||||||
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
|
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
|
||||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
|
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' }));
|
res.end(JSON.stringify({ ok: false, error: 'Túl sok publikálási kísérlet' }));
|
||||||
@@ -294,6 +308,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// (WHY comments there): commit only when staged changes exist, rebase with
|
// (WHY comments there): commit only when staged changes exist, rebase with
|
||||||
// abort-on-failure, deterministic no-changes marker instead of output matching.
|
// 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) => {
|
exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
|
||||||
|
isPublishing = false;
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
const outcome = interpretPublishResult(error, stdout, stderr);
|
const outcome = interpretPublishResult(error, stdout, stderr);
|
||||||
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });
|
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
target: builder # Use builder stage for development
|
target: builder # Use builder stage for development
|
||||||
container_name: mozdit-app-dev
|
container_name: mozdit-app-dev
|
||||||
ports:
|
ports:
|
||||||
- "8080:3000"
|
- "127.0.0.1:8080:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- MONGODB_URI=mongodb://mongodb:27017/mozdit
|
- MONGODB_URI=mongodb://mongodb:27017/mozdit
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
image: mongo:7.0
|
image: mongo:7.0
|
||||||
container_name: mozdit-mongodb-dev
|
container_name: mozdit-mongodb-dev
|
||||||
ports:
|
ports:
|
||||||
- "27018:27017"
|
- "127.0.0.1:27018:27017"
|
||||||
environment:
|
environment:
|
||||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
- MONGO_INITDB_ROOT_USERNAME=admin
|
||||||
- MONGO_INITDB_ROOT_PASSWORD=password123
|
- MONGO_INITDB_ROOT_PASSWORD=password123
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
image: mongo-express:1.0.2
|
image: mongo-express:1.0.2
|
||||||
container_name: mozdit-mongo-express-dev
|
container_name: mozdit-mongo-express-dev
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "127.0.0.1:8081:8081"
|
||||||
environment:
|
environment:
|
||||||
- ME_CONFIG_MONGODB_ADMINUSERNAME=admin
|
- ME_CONFIG_MONGODB_ADMINUSERNAME=admin
|
||||||
- ME_CONFIG_MONGODB_ADMINPASSWORD=password123
|
- ME_CONFIG_MONGODB_ADMINPASSWORD=password123
|
||||||
@@ -70,7 +70,7 @@ services:
|
|||||||
image: grafana/loki:2.9.0
|
image: grafana/loki:2.9.0
|
||||||
container_name: mozdit-loki-dev
|
container_name: mozdit-loki-dev
|
||||||
ports:
|
ports:
|
||||||
- "3100:3100"
|
- "127.0.0.1:3100:3100"
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
volumes:
|
volumes:
|
||||||
- loki_data:/loki
|
- loki_data:/loki
|
||||||
@@ -83,7 +83,7 @@ services:
|
|||||||
image: grafana/grafana:10.2.0
|
image: grafana/grafana:10.2.0
|
||||||
container_name: mozdit-grafana-dev
|
container_name: mozdit-grafana-dev
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "127.0.0.1:3001:3000"
|
||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin123
|
- GF_SECURITY_ADMIN_PASSWORD=admin123
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
- DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned}
|
- DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned}
|
||||||
container_name: mozdit-app-prod
|
container_name: mozdit-app-prod
|
||||||
ports:
|
ports:
|
||||||
- "8080:3000" # Host port 8080 elkerüli a lokális npm dev (3000) összeakadást
|
- "127.0.0.1:8080:3000" # Belső port — csak nginx reverse proxy-n keresztül elérhető
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
# No fallback for MONGODB_URI: with root auth enabled on the Mongo container an
|
# No fallback for MONGODB_URI: with root auth enabled on the Mongo container an
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
image: mongo:7.0
|
image: mongo:7.0
|
||||||
container_name: mozdit-mongodb-prod
|
container_name: mozdit-mongodb-prod
|
||||||
ports:
|
ports:
|
||||||
- "27018:27017" # Host port 27018 elkerüli az összeakadást a lokális Mongo-val
|
- "127.0.0.1:27018:27017" # Belső port, nem publikus az internet felé
|
||||||
environment:
|
environment:
|
||||||
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
|
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
|
||||||
# No weak default password: a missing value must fail the container loudly.
|
# No weak default password: a missing value must fail the container loudly.
|
||||||
|
|||||||
+35
-1
@@ -28,6 +28,24 @@ function getClientAddress(req) {
|
|||||||
return req.socket.remoteAddress || 'unknown';
|
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) {
|
function exceedsRateLimit(key, limit, windowMs) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
|
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
|
||||||
@@ -38,7 +56,10 @@ function exceedsRateLimit(key, limit, windowMs) {
|
|||||||
|
|
||||||
function hasValidCredentials(req, validateLogin) {
|
function hasValidCredentials(req, validateLogin) {
|
||||||
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
||||||
const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':');
|
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);
|
return validateLogin(login, password, CMS_USER, CMS_PASS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +105,17 @@ function backupAndWriteAtomically(targetFile, data, backupDir) {
|
|||||||
return backupFile;
|
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 = {
|
module.exports = {
|
||||||
CMS_USER,
|
CMS_USER,
|
||||||
CMS_PASS,
|
CMS_PASS,
|
||||||
@@ -91,6 +123,8 @@ module.exports = {
|
|||||||
CSRF_TOKEN,
|
CSRF_TOKEN,
|
||||||
securityConfigIsValid,
|
securityConfigIsValid,
|
||||||
getClientAddress,
|
getClientAddress,
|
||||||
|
isRateLimited,
|
||||||
|
recordRateLimitAttempt,
|
||||||
exceedsRateLimit,
|
exceedsRateLimit,
|
||||||
hasValidCredentials,
|
hasValidCredentials,
|
||||||
isBrowserNavigation,
|
isBrowserNavigation,
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ function renderPrimitive(path, val, container) {
|
|||||||
const type = val === null ? 'null' : typeof val;
|
const type = val === null ? 'null' : typeof val;
|
||||||
let control;
|
let control;
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
control = `<input type="checkbox" data-path="${path}" data-type="boolean" ${val ? 'checked' : ''}>`;
|
control = `<input type="checkbox" data-path="${esc(path)}" data-type="boolean" ${val ? 'checked' : ''}>`;
|
||||||
} else if (type === 'number') {
|
} else if (type === 'number') {
|
||||||
control = `<input type="number" data-path="${path}" data-type="number" value="${esc(val)}">`;
|
control = `<input type="number" data-path="${esc(path)}" data-type="number" value="${esc(val)}">`;
|
||||||
} else {
|
} else {
|
||||||
control = isLong
|
control = isLong
|
||||||
? `<textarea data-path="${path}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
|
? `<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="${path}" data-type="${type}" value="${esc(val ?? '')}">`;
|
: `<input type="text" data-path="${esc(path)}" data-type="${type}" value="${esc(val ?? '')}">`;
|
||||||
}
|
}
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<label>${path}</label>
|
<label>${path}</label>
|
||||||
@@ -242,41 +242,54 @@ function parsePath(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
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; }
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
const json = await res.json();
|
|
||||||
const status = document.getElementById('saveStatus');
|
const status = document.getElementById('saveStatus');
|
||||||
if (json.ok) {
|
try {
|
||||||
// Refresh the optimistic-lock fingerprint with the server-computed hash of
|
const data = collect();
|
||||||
// the saved content, so the user's own subsequent saves don't trip 409.
|
const res = await fetch('/save?file=' + FILE, {
|
||||||
if (json.contentHash) CONTENT_HASH = json.contentHash;
|
method: 'POST',
|
||||||
status.textContent = '✅ Mentve!';
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
|
||||||
status.style.color = '#10b981';
|
body: JSON.stringify(data, null, 2)
|
||||||
} else {
|
});
|
||||||
status.textContent = '❌ Hiba: ' + json.error;
|
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.color = '#f87171';
|
||||||
|
status.style.visibility = 'visible';
|
||||||
|
setTimeout(() => status.style.visibility = 'hidden', 5000);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
status.style.visibility = 'visible';
|
|
||||||
setTimeout(() => status.style.visibility = 'hidden', 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
const btn = document.getElementById('publishBtn');
|
const btn = document.getElementById('publishBtn');
|
||||||
const status = document.getElementById('saveStatus');
|
const status = document.getElementById('saveStatus');
|
||||||
|
|
||||||
// Save first
|
// Save first — abort publish if save failed (e.g. 409 conflict, validation error)
|
||||||
await save();
|
const saved = await save();
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
// WHY: lock the button width and remember the label so the running state
|
// 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.
|
// neither resizes the bottom bar nor permanently swaps the env-specific label.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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
|
// Page templates for the Content Editor. Kept separate so content-editor.js
|
||||||
// stays focused on routing/handling and below the file-size limits.
|
// stays focused on routing/handling and below the file-size limits.
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
|
${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'}">${message.text}</div>` : ''}
|
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${escHtml(message.text)}</div>` : ''}
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>mozdIT Content Editor</h1>
|
<h1>mozdIT Content Editor</h1>
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
|
|||||||
function buildPublishCommand(commitMessage) {
|
function buildPublishCommand(commitMessage) {
|
||||||
return [
|
return [
|
||||||
'git add .',
|
'git add .',
|
||||||
// WHY: logo uploads live in proto/public — outside the content cwd — so
|
// WHY: logo uploads live in proto/public — 2 levels above the content cwd — so
|
||||||
// stage them too (tolerant: optional path in test throwaway repos).
|
// stage them too (tolerant: optional path in test throwaway repos, stderr muted).
|
||||||
'(git add ../public || true)',
|
'(git add ../../public 2>/dev/null || true)',
|
||||||
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
|
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
|
||||||
'(git pull --rebase origin main || (git rebase --abort; false))',
|
'(git pull --rebase origin main || (git rebase --abort; false))',
|
||||||
'git push origin main',
|
'git push origin main',
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|||||||
const sessions = new Map(); // token -> expiresAt (ms)
|
const sessions = new Map(); // token -> expiresAt (ms)
|
||||||
|
|
||||||
function timingSafeMatch(candidate, expected) {
|
function timingSafeMatch(candidate, expected) {
|
||||||
if (typeof candidate !== 'string' || typeof expected !== 'string' || candidate.length !== expected.length) return false;
|
if (typeof candidate !== 'string' || typeof expected !== 'string') return false;
|
||||||
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
|
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) {
|
function validateLogin(user, pass, expectedUser, expectedPass) {
|
||||||
if (!expectedUser || !expectedPass) return false;
|
if (!expectedUser || !expectedPass) return false;
|
||||||
return timingSafeMatch(user, expectedUser) && timingSafeMatch(pass, expectedPass);
|
const userOk = timingSafeMatch(user, expectedUser);
|
||||||
|
const passOk = timingSafeMatch(pass, expectedPass);
|
||||||
|
return Boolean(userOk && passOk);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionCookie(isSecure) {
|
function createSessionCookie(isSecure) {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ function renderInline(text) {
|
|||||||
return escapeHtml(text)
|
return escapeHtml(text)
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
.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) {
|
function renderMarkdown(markdown) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ROOT = path.join(__dirname, '..');
|
|||||||
// ── Unit ─────────────────────────────────────────────────────────────────────
|
// ── Unit ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
|
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
|
||||||
assert.ok(cmd.startsWith('git add . && (git add ../public || true) && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
|
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.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
|
||||||
assert.ok(cmd.endsWith('git push origin main'), 'push last');
|
assert.ok(cmd.endsWith('git push origin main'), 'push last');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user