Refactor admin pages to use shared head and navigation components
- Created `admin-head.php` for consistent <head> markup across admin pages. - Created `admin-nav.php` for shared navigation bar, reducing duplication. - Updated all admin pages (account, hours, index, login, menu, users) to utilize the new components. - Added security headers to `auth.php` and `index.php` for improved security. - Introduced `security-headers.php` to manage security headers across the application. - Moved inline styles from admin pages to SCSS files to comply with Content Security Policy (CSP). - Normalized file permissions during build process to prevent permission issues on deployment.
This commit is contained in:
1 parent
3286bce6b1
commit
c7b836bcf2
21 files changed
+447
-231
No files matched your search
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/assets.php';
|
||||
|
||||
/**
|
||||
* Shared <head> for every admin page. $title is the page's own name; the
|
||||
* site suffix is added here so every tab reads "<Page> | The Old Fitz Admin".
|
||||
*/
|
||||
function render_admin_head(string $title): void {
|
||||
?>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= htmlspecialchars($title) ?> | The Old Fitz Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
||||
</head>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shared admin navigation bar. Every admin page calls
|
||||
* render_admin_nav($user, '<key>') where it used to repeat the markup.
|
||||
*
|
||||
* $active is one of the ADMIN_NAV_LINKS keys, or 'account' for the
|
||||
* My Account page (which highlights the username link instead).
|
||||
*/
|
||||
|
||||
const ADMIN_NAV_LINKS = [
|
||||
// key => [href, label, admin-only]
|
||||
'announcements' => ['index.php', 'Announcements', false],
|
||||
'menu' => ['menu.php', 'Menu', false],
|
||||
'users' => ['users.php', 'Users', true],
|
||||
'hours' => ['hours.php', 'Hours', false],
|
||||
];
|
||||
|
||||
function render_admin_nav(array $user, string $active): void {
|
||||
$isAdmin = ($user['role'] ?? '') === 'admin';
|
||||
$current = static fn (string $key): string =>
|
||||
$key === $active ? ' class="active" aria-current="page"' : '';
|
||||
?>
|
||||
<nav class="admin-nav">
|
||||
<div class="container admin-nav-inner">
|
||||
<a href="index.php" class="admin-brand">
|
||||
<img src="../assets/knot.png" alt="" class="knot">
|
||||
<span>The Old Fitz Admin</span>
|
||||
</a>
|
||||
<div class="admin-nav-links">
|
||||
<?php foreach (ADMIN_NAV_LINKS as $key => [$href, $label, $adminOnly]): ?>
|
||||
<?php if (!$adminOnly || $isAdmin): ?>
|
||||
<a href="<?= $href ?>"<?= $current($key) ?>><?= $label ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="admin-user">
|
||||
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
||||
<a href="account.php" class="admin-account-link<?= $active === 'account' ? ' active' : '' ?>" title="My Account"<?= $active === 'account' ? ' aria-current="page"' : '' ?>><?= htmlspecialchars($user['username']) ?></a>
|
||||
<a href="logout.php">Sign Out</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<?php
|
||||
}
|
||||
+2
-15
@@ -3,21 +3,8 @@
|
||||
* Auth & session helper
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the visitor reached us over HTTPS. TLS ends at the reverse proxy
|
||||
* (NPM or Caddy), so PHP itself only ever sees plain HTTP — trust the
|
||||
* proxy's X-Forwarded-Proto, but only from a private/loopback address so a
|
||||
* public client can't claim it.
|
||||
*/
|
||||
function request_is_https(): bool {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return true;
|
||||
}
|
||||
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$fromProxy = filter_var($remote, FILTER_VALIDATE_IP)
|
||||
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||||
return $fromProxy && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
}
|
||||
require_once __DIR__ . '/security-headers.php';
|
||||
send_security_headers();
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Configure secure session cookie defaults. 'secure' follows the
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Security headers for every page, sent from PHP so they apply the same
|
||||
* under NPM, Caddy, or local dev. Public pages call send_security_headers()
|
||||
* directly; admin pages get it through inc/auth.php.
|
||||
*
|
||||
* Static files (CSS, JS, images) are served by Apache and don't pass
|
||||
* through here — that's fine, since the CSP only matters on documents.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the visitor reached us over HTTPS. TLS ends at the reverse proxy
|
||||
* (NPM or Caddy), so PHP itself only ever sees plain HTTP — trust the
|
||||
* proxy's X-Forwarded-Proto, but only from a private/loopback address so a
|
||||
* public client can't claim it.
|
||||
*/
|
||||
function request_is_https(): bool {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return true;
|
||||
}
|
||||
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$fromProxy = filter_var($remote, FILTER_VALIDATE_IP)
|
||||
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||||
return $fromProxy && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
}
|
||||
|
||||
function send_security_headers(): void {
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Under `npm run dev`, browser-sync injects an inline <script> to load
|
||||
// its live-reload client and talks over a websocket. Allow exactly that
|
||||
// in dev only; production stays strict. Inline *styles* are blocked in
|
||||
// dev too, so a stray style="" still shows up while developing.
|
||||
$isDev = PHP_SAPI === 'cli-server';
|
||||
|
||||
$csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'" . ($isDev ? " 'unsafe-inline'" : ''),
|
||||
"style-src 'self' https://fonts.googleapis.com",
|
||||
"font-src https://fonts.gstatic.com",
|
||||
"img-src 'self'",
|
||||
"connect-src 'self'" . ($isDev ? ' ws: wss:' : ''),
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
];
|
||||
|
||||
header('Content-Security-Policy: ' . implode('; ', $csp));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
|
||||
// HSTS only over HTTPS (browsers ignore it on HTTP anyway). No
|
||||
// includeSubDomains: this site is one subdomain of daviddyer.me and
|
||||
// shouldn't dictate policy for the others.
|
||||
if (request_is_https()) {
|
||||
header('Strict-Transport-Security: max-age=31536000');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user