diff --git a/dist/.htaccess b/dist/.htaccess index 97dff0b..6262eb2 100644 --- a/dist/.htaccess +++ b/dist/.htaccess @@ -1,25 +1,22 @@ -# Deployed under /citpl_website/ on Apache. -# Requires: mod_rewrite, mod_headers, PHP curl -# API: PHP proxy to Node on 127.0.0.1:3001 (start with: npm run server / pm2) +# Deployed under /citpl_website/ — Apache must proxy API to the Node server. +# Requires: mod_rewrite, mod_proxy, mod_proxy_http, mod_headers +# Start Node on the host: npm run server (or pm2 start server/index.js) RewriteEngine On RewriteBase /citpl_website/ - # API -> PHP proxy (works without mod_proxy) - RewriteRule ^api(?:/.*)?$ api/index.php [QSA,L] - - # Uploads -> PHP proxy - RewriteRule ^uploads/(.*)$ uploads-proxy.php?file=$1 [QSA,L] + # Proxy API + uploads to Express (Node) on port 3001 + RewriteRule ^api/(.*)$ http://127.0.0.1:3001/api/$1 [P,L] + RewriteRule ^uploads/(.*)$ http://127.0.0.1:3001/uploads/$1 [P,L] -# Host security headers use frame-ancestors 'none' + X-Frame-Options DENY. -# Edit them so admin can embed preview.html (same origin). +# Allow admin login preview iframe (same origin) Header always edit Content-Security-Policy "frame-ancestors 'none'" "frame-ancestors 'self'" Header always edit X-Frame-Options "DENY" "SAMEORIGIN" - + Header unset X-Frame-Options Header always unset X-Frame-Options Header unset Content-Security-Policy diff --git a/dist/api/index.php b/dist/api/index.php deleted file mode 100644 index 035169d..0000000 --- a/dist/api/index.php +++ /dev/null @@ -1,119 +0,0 @@ - $value) { - $lower = strtolower((string) $name); - if ($lower === 'host' || $lower === 'content-length') { - continue; - } - $headers[] = $name . ': ' . $value; - } -} else { - foreach ($_SERVER as $key => $value) { - if (strpos($key, 'HTTP_') !== 0) { - continue; - } - $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); - if (strtolower($name) === 'host') { - continue; - } - $headers[] = $name . ': ' . $value; - } - if (!empty($_SERVER['CONTENT_TYPE'])) { - $headers[] = 'Content-Type: ' . $_SERVER['CONTENT_TYPE']; - } -} - -if (!function_exists('curl_init')) { - http_response_code(500); - header('Content-Type: application/json'); - echo json_encode([ - 'error' => 'PHP curl extension is required for the API proxy', - 'code' => 'PROXY_MISCONFIGURED', - ]); - exit; -} - -$ch = curl_init($url); -curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 60, - CURLOPT_HTTPHEADER => $headers, -]); - -if ($method !== 'GET' && $method !== 'HEAD') { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); -} - -$response = curl_exec($ch); -if ($response === false) { - $err = curl_error($ch); - curl_close($ch); - http_response_code(502); - header('Content-Type: application/json'); - echo json_encode([ - 'error' => 'API server unreachable. On the host run: npm run server (port 3001)', - 'detail' => $err, - 'code' => 'BAD_GATEWAY', - ]); - exit; -} - -$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); -$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); -curl_close($ch); - -$rawHeaders = substr($response, 0, $headerSize); -$responseBody = substr($response, $headerSize); - -http_response_code($status > 0 ? $status : 502); - -$skip = ['transfer-encoding', 'connection', 'keep-alive', 'content-length']; -foreach (explode("\r\n", $rawHeaders) as $line) { - if ($line === '' || stripos($line, 'HTTP/') === 0) { - continue; - } - $parts = explode(':', $line, 2); - if (count($parts) < 2) { - continue; - } - $name = trim($parts[0]); - if (in_array(strtolower($name), $skip, true)) { - continue; - } - header($name . ':' . $parts[1], false); -} - -echo $responseBody; diff --git a/dist/uploads-proxy.php b/dist/uploads-proxy.php deleted file mode 100644 index c64afd0..0000000 --- a/dist/uploads-proxy.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Missing file', 'code' => 'VALIDATION']); - exit; -} - -$url = rtrim($apiOrigin, '/') . '/uploads/' . str_replace(' ', '%20', $file); - -if (!function_exists('curl_init')) { - http_response_code(500); - exit('curl required'); -} - -$ch = curl_init($url); -curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 60, -]); - -$response = curl_exec($ch); -if ($response === false) { - curl_close($ch); - http_response_code(502); - exit('Upload server unreachable'); -} - -$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); -$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); -curl_close($ch); - -http_response_code($status > 0 ? $status : 502); -$rawHeaders = substr($response, 0, $headerSize); -$body = substr($response, $headerSize); - -$skip = ['transfer-encoding', 'connection', 'keep-alive']; -foreach (explode("\r\n", $rawHeaders) as $line) { - if ($line === '' || stripos($line, 'HTTP/') === 0) { - continue; - } - $parts = explode(':', $line, 2); - if (count($parts) < 2) { - continue; - } - $name = trim($parts[0]); - if (in_array(strtolower($name), $skip, true)) { - continue; - } - header($name . ':' . $parts[1], false); -} - -echo $body; diff --git a/package.json b/package.json index b6bc917..158e040 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "concurrently \"npm run server\" \"vite\"", "dev:client": "vite", "server": "node server/index.js", + "start": "node server/index.js", "build": "vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/public/.htaccess b/public/.htaccess index 97dff0b..6262eb2 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,25 +1,22 @@ -# Deployed under /citpl_website/ on Apache. -# Requires: mod_rewrite, mod_headers, PHP curl -# API: PHP proxy to Node on 127.0.0.1:3001 (start with: npm run server / pm2) +# Deployed under /citpl_website/ — Apache must proxy API to the Node server. +# Requires: mod_rewrite, mod_proxy, mod_proxy_http, mod_headers +# Start Node on the host: npm run server (or pm2 start server/index.js) RewriteEngine On RewriteBase /citpl_website/ - # API -> PHP proxy (works without mod_proxy) - RewriteRule ^api(?:/.*)?$ api/index.php [QSA,L] - - # Uploads -> PHP proxy - RewriteRule ^uploads/(.*)$ uploads-proxy.php?file=$1 [QSA,L] + # Proxy API + uploads to Express (Node) on port 3001 + RewriteRule ^api/(.*)$ http://127.0.0.1:3001/api/$1 [P,L] + RewriteRule ^uploads/(.*)$ http://127.0.0.1:3001/uploads/$1 [P,L] -# Host security headers use frame-ancestors 'none' + X-Frame-Options DENY. -# Edit them so admin can embed preview.html (same origin). +# Allow admin login preview iframe (same origin) Header always edit Content-Security-Policy "frame-ancestors 'none'" "frame-ancestors 'self'" Header always edit X-Frame-Options "DENY" "SAMEORIGIN" - + Header unset X-Frame-Options Header always unset X-Frame-Options Header unset Content-Security-Policy diff --git a/public/api/index.php b/public/api/index.php deleted file mode 100644 index 035169d..0000000 --- a/public/api/index.php +++ /dev/null @@ -1,119 +0,0 @@ - $value) { - $lower = strtolower((string) $name); - if ($lower === 'host' || $lower === 'content-length') { - continue; - } - $headers[] = $name . ': ' . $value; - } -} else { - foreach ($_SERVER as $key => $value) { - if (strpos($key, 'HTTP_') !== 0) { - continue; - } - $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); - if (strtolower($name) === 'host') { - continue; - } - $headers[] = $name . ': ' . $value; - } - if (!empty($_SERVER['CONTENT_TYPE'])) { - $headers[] = 'Content-Type: ' . $_SERVER['CONTENT_TYPE']; - } -} - -if (!function_exists('curl_init')) { - http_response_code(500); - header('Content-Type: application/json'); - echo json_encode([ - 'error' => 'PHP curl extension is required for the API proxy', - 'code' => 'PROXY_MISCONFIGURED', - ]); - exit; -} - -$ch = curl_init($url); -curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 60, - CURLOPT_HTTPHEADER => $headers, -]); - -if ($method !== 'GET' && $method !== 'HEAD') { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); -} - -$response = curl_exec($ch); -if ($response === false) { - $err = curl_error($ch); - curl_close($ch); - http_response_code(502); - header('Content-Type: application/json'); - echo json_encode([ - 'error' => 'API server unreachable. On the host run: npm run server (port 3001)', - 'detail' => $err, - 'code' => 'BAD_GATEWAY', - ]); - exit; -} - -$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); -$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); -curl_close($ch); - -$rawHeaders = substr($response, 0, $headerSize); -$responseBody = substr($response, $headerSize); - -http_response_code($status > 0 ? $status : 502); - -$skip = ['transfer-encoding', 'connection', 'keep-alive', 'content-length']; -foreach (explode("\r\n", $rawHeaders) as $line) { - if ($line === '' || stripos($line, 'HTTP/') === 0) { - continue; - } - $parts = explode(':', $line, 2); - if (count($parts) < 2) { - continue; - } - $name = trim($parts[0]); - if (in_array(strtolower($name), $skip, true)) { - continue; - } - header($name . ':' . $parts[1], false); -} - -echo $responseBody; diff --git a/public/uploads-proxy.php b/public/uploads-proxy.php deleted file mode 100644 index c64afd0..0000000 --- a/public/uploads-proxy.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Missing file', 'code' => 'VALIDATION']); - exit; -} - -$url = rtrim($apiOrigin, '/') . '/uploads/' . str_replace(' ', '%20', $file); - -if (!function_exists('curl_init')) { - http_response_code(500); - exit('curl required'); -} - -$ch = curl_init($url); -curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 60, -]); - -$response = curl_exec($ch); -if ($response === false) { - curl_close($ch); - http_response_code(502); - exit('Upload server unreachable'); -} - -$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); -$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); -curl_close($ch); - -http_response_code($status > 0 ? $status : 502); -$rawHeaders = substr($response, 0, $headerSize); -$body = substr($response, $headerSize); - -$skip = ['transfer-encoding', 'connection', 'keep-alive']; -foreach (explode("\r\n", $rawHeaders) as $line) { - if ($line === '' || stripos($line, 'HTTP/') === 0) { - continue; - } - $parts = explode(':', $line, 2); - if (count($parts) < 2) { - continue; - } - $name = trim($parts[0]); - if (in_array(strtolower($name), $skip, true)) { - continue; - } - header($name . ':' . $parts[1], false); -} - -echo $body; diff --git a/server/db.js b/server/db.js index bfada3d..c500285 100644 --- a/server/db.js +++ b/server/db.js @@ -34,14 +34,15 @@ export function initDb() { console.log('Seeded content database from default-content.json'); } - if (!fs.existsSync(ADMIN_FILE)) { - const password = process.env.ADMIN_PASSWORD || 'admin123'; - const hash = bcrypt.hashSync(password, 12); + // Keep login credentials in sync with .env (ADMIN_USERNAME / ADMIN_PASSWORD) + const username = process.env.ADMIN_USERNAME || 'admin'; + const password = process.env.ADMIN_PASSWORD || 'admin123'; + if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD || !fs.existsSync(ADMIN_FILE)) { writeJson(ADMIN_FILE, { - username: process.env.ADMIN_USERNAME || 'admin', - passwordHash: hash, + username, + passwordHash: bcrypt.hashSync(password, 12), }); - console.log(`Admin user created (username: ${process.env.ADMIN_USERNAME || 'admin'})`); + console.log(`Admin credentials loaded from .env (username: ${username})`); } } diff --git a/server/index.js b/server/index.js index 5ce2c95..e485ff4 100644 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,7 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import path from 'path'; +import fs from 'fs'; import { fileURLToPath } from 'url'; import { initDb } from './db.js'; import authRoutes from './routes/auth.js'; @@ -12,6 +13,13 @@ import multer from 'multer'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT || 3001; const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'); +// Public URL prefix on Apache (e.g. /citpl_website). Leave empty if site is at domain root. +const SITE_BASE = (process.env.SITE_BASE || '/citpl_website').replace(/\/$/, ''); +const distDir = path.join(__dirname, '..', 'dist'); +const serveStatic = + process.env.SERVE_STATIC === '1' || + process.env.SERVE_STATIC === 'true' || + (process.env.SERVE_STATIC !== '0' && fs.existsSync(distDir)); initDb(); @@ -23,22 +31,30 @@ app.use(cors({ })); app.use(express.json({ limit: '10mb' })); -app.use('/uploads', express.static(UPLOAD_DIR)); -app.get('/api/health', (_req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); +function mountApp(base) { + const prefix = base || ''; -app.use('/api/auth', authRoutes); -app.use('/api/content', contentRoutes); -app.use('/api/upload', uploadRoutes); + app.use(`${prefix}/uploads`, express.static(UPLOAD_DIR)); -// Optional: serve Vite dist from Node (set SERVE_STATIC=1). Useful when Apache -// proxies the whole /citpl_website path to this process. -if (process.env.SERVE_STATIC === '1') { - const distDir = path.join(__dirname, '..', 'dist'); - app.use(express.static(distDir)); - app.use('/citpl_website', express.static(distDir)); + app.get(`${prefix}/api/health`, (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + + app.use(`${prefix}/api/auth`, authRoutes); + app.use(`${prefix}/api/content`, contentRoutes); + app.use(`${prefix}/api/upload`, uploadRoutes); + + if (serveStatic) { + app.use(prefix || '/', express.static(distDir)); + } +} + +// Root paths — used when Apache proxies /citpl_website/api → http://127.0.0.1:3001/api +mountApp(''); +// Prefixed paths — used when Apache proxies /citpl_website → http://127.0.0.1:3001/citpl_website +if (SITE_BASE) { + mountApp(SITE_BASE); } app.use((err, _req, res, _next) => { @@ -50,5 +66,10 @@ app.use((err, _req, res, _next) => { }); app.listen(PORT, () => { - console.log(`API server running on http://localhost:${PORT}`); + console.log(`CITPL Node server on http://localhost:${PORT}`); + console.log(` API: /api/* and ${SITE_BASE || ''}/api/*`); + console.log(` Auth: ADMIN_USERNAME from .env (${process.env.ADMIN_USERNAME || 'admin'})`); + if (serveStatic) { + console.log(` Static: ${distDir}`); + } });