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:
David Dyer committed 2026-10-25 16:06:15 -04:00
1 parent 3286bce6b1
commit c7b836bcf2
21 files changed
+447 -231

No files matched your search

+4 -31
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/admin-head.php';
require_once __DIR__ . '/../inc/admin-nav.php';
require_once __DIR__ . '/../inc/users.php'; require_once __DIR__ . '/../inc/users.php';
// Any signed-in user can change their own username and/or password here, // Any signed-in user can change their own username and/or password here,
@@ -80,46 +82,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$justSaved = !$_POST && isset($_GET['saved']); $justSaved = !$_POST && isset($_GET['saved']);
$isAdmin = ($user['role'] ?? '') === 'admin';
// After a failed save, keep the username they typed in the field. // After a failed save, keep the username they typed in the field.
$usernameValue = $_POST ? trim(account_post_str('username')) : $user['username']; $usernameValue = $_POST ? trim(account_post_str('username')) : $user['username'];
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <?php render_admin_head('My Account'); ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Account | 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>
<body> <body>
<nav class="admin-nav"> <?php render_admin_nav($user, 'account'); ?>
<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">
<a href="index.php">Announcements</a>
<a href="menu.php">Menu</a>
<?php if ($isAdmin): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link active" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
<a href="logout.php">Sign Out</a>
</div>
</div>
</nav>
<main class="dashboard-layout"> <main class="dashboard-layout">
<div class="container"> <div class="container">
+6 -32
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/admin-head.php';
require_once __DIR__ . '/../inc/admin-nav.php';
require_once __DIR__ . '/../inc/hours.php'; require_once __DIR__ . '/../inc/hours.php';
require_auth(); require_auth();
@@ -66,39 +68,11 @@ $justSaved = !$_POST && isset($_GET['saved']);
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <?php render_admin_head('Hours'); ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hours | 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>
<body> <body>
<nav class="admin-nav"> <?php render_admin_nav($user, 'hours'); ?>
<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">
<a href="index.php">Announcements</a>
<a href="menu.php">Menu</a>
<?php if (($user['role'] ?? '') === 'admin'): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php" class="active">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
<a href="logout.php">Sign Out</a>
</div>
</div>
</nav>
<main class="dashboard-layout"> <main class="dashboard-layout">
<div class="container"> <div class="container">
@@ -157,7 +131,7 @@ $justSaved = !$_POST && isset($_GET['saved']);
</div> </div>
</div> </div>
<p class="muted field-hint" style="margin-top: 1.25rem;">Leave "Kitchen Closes" blank on a day if the kitchen doesn't have a separate cutoff from the bar. Special closures (holidays, weather) are handled on the <a href="index.php">Announcements</a> page instead of here.</p> <p class="muted field-hint hours-note">Leave "Kitchen Closes" blank on a day if the kitchen doesn't have a separate cutoff from the bar. Special closures (holidays, weather) are handled on the <a href="index.php">Announcements</a> page instead of here.</p>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Hours</button> <button type="submit" class="btn btn-primary">Save Hours</button>
@@ -171,7 +145,7 @@ $justSaved = !$_POST && isset($_GET['saved']);
<span>👀 Public Site Preview</span> <span>👀 Public Site Preview</span>
</h2> </h2>
</div> </div>
<p class="muted" style="margin-bottom: 1.25rem;">This is how the schedule above will read on the site once saved.</p> <p class="muted hours-preview-intro">This is how the schedule above will read on the site once saved.</p>
<div class="hours-preview"> <div class="hours-preview">
<div class="hours-preview-card"> <div class="hours-preview-card">
+10 -36
View File
@@ -1,6 +1,8 @@
<?php <?php
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/admin-head.php';
require_once __DIR__ . '/../inc/admin-nav.php';
require_once __DIR__ . '/../inc/announcements.php'; require_once __DIR__ . '/../inc/announcements.php';
require_auth(); require_auth();
@@ -102,38 +104,10 @@ $announcements = get_all_announcements();
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <?php render_admin_head('Announcements'); ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pub Management Dashboard | The Old Fitz</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>
<body> <body>
<nav class="admin-nav"> <?php render_admin_nav($user, 'announcements'); ?>
<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">
<a href="index.php" class="active">Announcements</a>
<a href="menu.php">Menu</a>
<?php if (($user['role'] ?? '') === 'admin'): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
<a href="logout.php">Sign Out</a>
</div>
</div>
</nav>
<main class="dashboard-layout"> <main class="dashboard-layout">
<div class="container"> <div class="container">
@@ -194,21 +168,21 @@ $announcements = get_all_announcements();
</div> </div>
<div data-label="Headline"> <div data-label="Headline">
<strong><?= htmlspecialchars($ann['headline']) ?></strong> <strong><?= htmlspecialchars($ann['headline']) ?></strong>
<div class="muted" style="font-size: 0.82rem; margin-top: 0.2rem; max-width: 32ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"> <div class="muted ann-summary">
<?= htmlspecialchars($ann['message']) ?> <?= htmlspecialchars($ann['message']) ?>
</div> </div>
</div> </div>
<div data-label="Type"> <div data-label="Type">
<span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span> <span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span>
</div> </div>
<div class="font-mono" data-label="Schedule" style="font-size: 0.8rem; color: var(--ink-muted);"> <div class="font-mono ann-schedule" data-label="Schedule">
<?php if (!empty($ann['start_date']) || !empty($ann['end_date'])): ?> <?php if (!empty($ann['start_date']) || !empty($ann['end_date'])): ?>
<?= htmlspecialchars($ann['start_date'] ?: 'Now') ?> → <?= htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?> <?= htmlspecialchars($ann['start_date'] ?: 'Now') ?> → <?= htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
<?php else: ?> <?php else: ?>
Always (while on) Always (while on)
<?php endif; ?> <?php endif; ?>
</div> </div>
<div data-label="Placements" style="font-size: 0.82rem; color: var(--ink-muted);"> <div class="ann-placements" data-label="Placements">
<?php <?php
$locs = []; $locs = [];
if (!empty($ann['show_banner'])) $locs[] = 'Top Banner'; if (!empty($ann['show_banner'])) $locs[] = 'Top Banner';
@@ -221,7 +195,7 @@ $announcements = get_all_announcements();
<div class="action-stack"> <div class="action-stack">
<a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a> <a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a>
<form method="POST" action="index.php" style="display: inline;" class="js-confirm-submit" data-confirm="Are you sure you want to delete this announcement?"> <form method="POST" action="index.php" class="inline-form js-confirm-submit" data-confirm="Are you sure you want to delete this announcement?">
<input type="hidden" name="action" value="delete_announcement"> <input type="hidden" name="action" value="delete_announcement">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>"> <input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
@@ -286,14 +260,14 @@ $announcements = get_all_announcements();
<label for="start_date">Start Date (Optional Auto-Schedule)</label> <label for="start_date">Start Date (Optional Auto-Schedule)</label>
<input type="date" id="start_date" name="start_date" class="form-control" <input type="date" id="start_date" name="start_date" class="form-control"
value="<?= htmlspecialchars($editItem['start_date'] ?? '') ?>"> value="<?= htmlspecialchars($editItem['start_date'] ?? '') ?>">
<span class="muted font-mono" style="font-size: 0.78rem;">Leave blank to show immediately while enabled.</span> <span class="muted font-mono ann-field-hint">Leave blank to show immediately while enabled.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="end_date">End Date (Optional Expiration)</label> <label for="end_date">End Date (Optional Expiration)</label>
<input type="date" id="end_date" name="end_date" class="form-control" <input type="date" id="end_date" name="end_date" class="form-control"
value="<?= htmlspecialchars($editItem['end_date'] ?? '') ?>"> value="<?= htmlspecialchars($editItem['end_date'] ?? '') ?>">
<span class="muted font-mono" style="font-size: 0.78rem;">Auto-hides after 11:59 PM on this date.</span> <span class="muted font-mono ann-field-hint">Auto-hides after 11:59 PM on this date.</span>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
+2 -9
View File
@@ -2,6 +2,7 @@
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/login-throttle.php'; require_once __DIR__ . '/../inc/login-throttle.php';
require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/admin-head.php';
$error = ''; $error = '';
@@ -27,15 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <?php render_admin_head('Sign In'); ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Login | The Old Fitz Public House</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:wght@600&family=Karla:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
</head>
<body class="admin-login-body"> <body class="admin-login-body">
<div class="login-card"> <div class="login-card">
<div class="login-header"> <div class="login-header">
+9 -3
View File
@@ -71,10 +71,16 @@ function closeCategoryModal() {
catModal.close(); catModal.close();
} }
// Escapes for both text and quoted-attribute positions. The old version
// (textContent → innerHTML) didn't escape quotes, so a name like
// The "Big" Burger broke out of an aria-label="..." attribute.
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); return String(text ?? '')
div.textContent = text ?? ''; .replaceAll('&', '&amp;')
return div.innerHTML; .replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
} }
async function postForm(formData) { async function postForm(formData) {
+4 -30
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/admin-head.php';
require_once __DIR__ . '/../inc/admin-nav.php';
require_once __DIR__ . '/../inc/menu.php'; require_once __DIR__ . '/../inc/menu.php';
require_auth(); require_auth();
@@ -180,39 +182,11 @@ $menuEnabled = is_menu_enabled();
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <?php render_admin_head('Menu'); ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Menu Management | 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>
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>"> <body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
<nav class="admin-nav"> <?php render_admin_nav($user, 'menu'); ?>
<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">
<a href="index.php">Announcements</a>
<a href="menu.php" class="active">Menu</a>
<?php if (($user['role'] ?? '') === 'admin'): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
<a href="logout.php">Sign Out</a>
</div>
</div>
</nav>
<main class="dashboard-layout"> <main class="dashboard-layout">
<div class="container"> <div class="container">
+9 -3
View File
@@ -9,10 +9,16 @@ const userModal = document.getElementById('modal-user');
const passwordModal = document.getElementById('modal-password'); const passwordModal = document.getElementById('modal-password');
const editModal = document.getElementById('modal-edit'); const editModal = document.getElementById('modal-edit');
// Escapes for both text and quoted-attribute positions. The old version
// (textContent → innerHTML) didn't escape quotes, so a name like
// The "Big" Burger broke out of an aria-label="..." attribute.
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); return String(text ?? '')
div.textContent = text ?? ''; .replaceAll('&', '&amp;')
return div.innerHTML; .replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
} }
async function postForm(formData) { async function postForm(formData) {
+4 -28
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/admin-head.php';
require_once __DIR__ . '/../inc/admin-nav.php';
require_once __DIR__ . '/../inc/users.php'; require_once __DIR__ . '/../inc/users.php';
// User management is admin-only. Other pages don't check roles yet — // User management is admin-only. Other pages don't check roles yet —
@@ -190,37 +192,11 @@ $users = get_all_users();
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <?php render_admin_head('Users'); ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>User Management | 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>
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>"> <body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>">
<nav class="admin-nav"> <?php render_admin_nav($user, 'users'); ?>
<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">
<a href="index.php">Announcements</a>
<a href="menu.php">Menu</a>
<a href="users.php" class="active">Users</a>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
<a href="logout.php">Sign Out</a>
</div>
</div>
</nav>
<main class="dashboard-layout"> <main class="dashboard-layout">
<div class="container"> <div class="container">
+39 -33
View File
@@ -1,52 +1,58 @@
# The Old Fitz — production compose for the VPS.
#
# Expected layout, one folder per site:
# theoldfitz/
# docker-compose.yaml (this file)
# .env (optional, see PROXY_NETWORK below)
# www/ (contents of `npm run dist`)
# data/ (fitz.sqlite — never inside www/)
#
# First run: create data/ and `chown 33:33 data` (www-data in the image),
# then create the first admin with bin/create-user.php (see its header).
#
# The CasaOS dev host keeps its own copy of this file in the CasaOS app
# settings, with CasaOS paths and its x-casaos block.
name: the-old-fitz name: the-old-fitz
networks: networks:
nginxproxymanager: proxy:
name: nginxproxymanager
external: true external: true
# The reverse proxy's Docker network (NPM or Caddy). Set PROXY_NETWORK
# in .env if it isn't called "proxy".
name: ${PROXY_NETWORK:-proxy}
services: services:
php-server: the-old-fitz:
image: php:8.5-apache image: php:8.5-apache
container_name: the-old-fitz container_name: the-old-fitz
restart: unless-stopped restart: unless-stopped
# The image ships without a php.ini, so PHP falls back to dev-style
# defaults and shows errors (with server paths) to visitors. Activate
# the image's bundled production ini — display_errors off, errors to
# the log — and hide the PHP version header, then start Apache as usual.
# Paths are spelled out (the image's $PHP_INI_DIR) rather than using
# the variable, so no $-escaping is needed.
command:
- bash
- -c
- cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && echo 'expose_php = Off' > /usr/local/etc/php/conf.d/zz-hardening.ini && exec apache2-foreground
networks: networks:
- nginxproxymanager - proxy
volumes: volumes:
- type: bind - type: bind
source: /DATA/AppData/theoldfitz/www source: ./www
target: /var/www/html target: /var/www/html
# Database lives outside the web root so Apache can't serve it. # Database lives outside the web root so Apache can't serve it.
# FITZ_DATA_DIR defaults to the CasaOS dev host's path; set it in a
# .env file next to this compose file on the VPS.
- type: bind - type: bind
source: ${FITZ_DATA_DIR:-/DATA/AppData/theoldfitz/data} source: ./data
target: /var/www/data target: /var/www/data
environment: environment:
- TZ=America/Indiana/Indianapolis - TZ=America/Indiana/Indianapolis
- FITZ_DB_PATH=/var/www/data/fitz.sqlite - FITZ_DB_PATH=/var/www/data/fitz.sqlite
labels: # Cap container logs so PHP's error log can't slowly fill the disk.
icon: https://www.php.net/images/logos/php-logo.svg logging:
driver: json-file
x-casaos: options:
architectures: max-size: 10m
- amd64 max-file: "3"
- arm64
author: self
category: Developer
description:
en_us: PHP web server running Apache, proxied through Nginx Proxy Manager.
developer: PHP
hostname: theoldfitz.daviddyer.me
icon: https://www.php.net/images/logos/php-logo.svg
id: self.php-server
index: /
is_uncontrolled: false
main: php-server
port_map: "80"
scheme: http
tagline:
en_us: PHP 8.5 + Apache web server.
thumbnail: https://www.php.net/images/logos/php-logo.svg
title:
en_us: PHP Server
+23
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -3,21 +3,8 @@
* Auth & session helper * Auth & session helper
*/ */
/** require_once __DIR__ . '/security-headers.php';
* Whether the visitor reached us over HTTPS. TLS ends at the reverse proxy send_security_headers();
* (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';
}
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
// Configure secure session cookie defaults. 'secure' follows the // Configure secure session cookie defaults. 'secure' follows the
+65
View File
@@ -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');
}
}
+2
View File
@@ -1,6 +1,8 @@
<?php <?php
require __DIR__ . '/config.php'; require __DIR__ . '/config.php';
require __DIR__ . '/inc/assets.php'; require __DIR__ . '/inc/assets.php';
require __DIR__ . '/inc/security-headers.php';
send_security_headers();
require __DIR__ . '/inc/google-reviews.php'; require __DIR__ . '/inc/google-reviews.php';
require __DIR__ . '/inc/announcements.php'; require __DIR__ . '/inc/announcements.php';
require __DIR__ . '/inc/hours.php'; require __DIR__ . '/inc/hours.php';
+13 -11
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require __DIR__ . '/config.php'; require __DIR__ . '/config.php';
require __DIR__ . '/inc/assets.php'; require __DIR__ . '/inc/assets.php';
require __DIR__ . '/inc/security-headers.php';
send_security_headers();
require __DIR__ . '/inc/announcements.php'; require __DIR__ . '/inc/announcements.php';
require __DIR__ . '/inc/menu.php'; require __DIR__ . '/inc/menu.php';
require __DIR__ . '/inc/hours.php'; require __DIR__ . '/inc/hours.php';
@@ -94,24 +96,24 @@ $siteUrl = rtrim(SITE_URL, '/');
<main id="main"> <main id="main">
<section class="menu-page-header"> <section class="menu-page-header">
<div class="container" style="text-align: center;"> <div class="container menu-hero">
<p class="eyebrow">The Old Fitz Public House</p> <p class="eyebrow">The Old Fitz Public House</p>
<h1 style="font-size: clamp(2.4rem, 5vw, 3.8rem); color: var(--gold-bright); margin-top: 0.5rem;">Pub Menu</h1> <h1 class="menu-hero-title">Pub Menu</h1>
<div class="rule-row" style="margin-top: 1.5rem; margin-bottom: 1.5rem;"> <div class="rule-row menu-hero-rule">
<span class="line"></span> <span class="line"></span>
<img class="knot" src="./assets/knot.png" alt=""> <img class="knot" src="./assets/knot.png" alt="">
<span class="line"></span> <span class="line"></span>
</div> </div>
<p class="muted" style="max-width: 52ch; margin: 0 auto; font-size: 1.05rem;"> <p class="muted menu-hero-intro">
Traditional Irish staples, shareable plates, and comforting pub classics cooked fresh. Kitchen serves until 9:00 PM. Traditional Irish staples, shareable plates, and comforting pub classics cooked fresh. Kitchen serves until 9:00 PM.
</p> </p>
</div> </div>
</section> </section>
<section class="menu-page-content" style="padding-top: 0;"> <section class="menu-page-content">
<div class="container" style="max-width: 62rem;"> <div class="container menu-page-container">
<?php if ($menuEnabled && !empty($menuCategories)): ?> <?php if ($menuEnabled && !empty($menuCategories)): ?>
<div class="menu-grid" style="margin-top: 0;"> <div class="menu-grid menu-grid-flush">
<?php foreach ($menuCategories as $category): ?> <?php foreach ($menuCategories as $category): ?>
<?php if (!empty($category['items'])): ?> <?php if (!empty($category['items'])): ?>
<div class="menu-category"> <div class="menu-category">
@@ -144,17 +146,17 @@ $siteUrl = rtrim(SITE_URL, '/');
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="panel" style="text-align: center; padding: 3rem 1.5rem;"> <div class="panel menu-empty">
<p class="muted">Our kitchen menu is currently being refreshed. Please check our Facebook page for today's specials!</p> <p class="muted">Our kitchen menu is currently being refreshed. Please check our Facebook page for today's specials!</p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Allergen / Food Safety Disclaimer from the original print menu --> <!-- Allergen / Food Safety Disclaimer from the original print menu -->
<div class="menu-disclaimer" style="margin-top: 3.5rem; text-align: center; padding-top: 2rem; border-top: 1px solid var(--rule);"> <div class="menu-disclaimer">
<p class="muted" style="font-size: 0.8rem; line-height: 1.6; max-width: 68ch; margin: 0 auto;"> <p class="muted menu-disclaimer-text">
Consuming raw or undercooked meats, poultry, seafood, shellfish, or eggs may increase your risk of foodborne illness. Our kitchen handles a variety of ingredients, and our menu items may contain or come into contact with common allergens such as milk, eggs, wheat, soy, nuts, or shellfish. Consuming raw or undercooked meats, poultry, seafood, shellfish, or eggs may increase your risk of foodborne illness. Our kitchen handles a variety of ingredients, and our menu items may contain or come into contact with common allergens such as milk, eggs, wheat, soy, nuts, or shellfish.
</p> </p>
<div class="hero-ctas" style="margin-top: 2rem; justify-content: center;"> <div class="hero-ctas menu-disclaimer-ctas">
<a class="btn btn-primary" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">See Daily Specials on Facebook</a> <a class="btn btn-primary" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">See Daily Specials on Facebook</a>
<a class="btn btn-outline" href="mailto:info@theoldfitzpub.com">Ask a Kitchen Question</a> <a class="btn btn-outline" href="mailto:info@theoldfitzpub.com">Ask a Kitchen Question</a>
</div> </div>
+19
View File
@@ -74,6 +74,25 @@ if (fs.existsSync(dbHtaccess)) {
missing.push('db/.htaccess'); missing.push('db/.htaccess');
} }
// Normalize permissions: 755 for directories, 644 for files. Copies keep
// the source file's mode, so a file that's owner-only on the dev machine
// (e.g. after an in-place edit) would ship unreadable to Apache's
// www-data and 500 the page. Forcing the modes here means local
// permissions can never break a deploy.
function normalizePermissions(dir) {
fs.chmodSync(dir, 0o755);
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
normalizePermissions(full);
} else {
fs.chmodSync(full, 0o644);
}
}
}
normalizePermissions(DIST);
if (missing.length) { if (missing.length) {
console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n ')); console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n '));
} }
+29
View File
@@ -0,0 +1,29 @@
/* ---------- admin: announcements ---------- */
/* Formerly inline style="" attributes on admin/index.php — moved here so
the CSP can forbid inline styles. */
.ann-summary {
font-size: 0.82rem;
margin-top: 0.2rem;
max-width: 32ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ann-schedule {
font-size: 0.8rem;
color: var(--ink-muted);
}
.ann-placements {
font-size: 0.82rem;
color: var(--ink-muted);
}
.ann-field-hint {
font-size: 0.78rem;
}
.inline-form {
display: inline;
}
+9
View File
@@ -141,3 +141,12 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Formerly inline style="" attributes on admin/hours.php. */
.hours-note {
margin-top: 1.25rem;
}
.hours-preview-intro {
margin-bottom: 1.25rem;
}
+56
View File
@@ -94,5 +94,61 @@
} }
.menu-page-content { .menu-page-content {
padding-top: 0;
padding-bottom: 5.5rem; padding-bottom: 5.5rem;
} }
/* Formerly inline style="" attributes on menu.php — moved here so the
CSP can forbid inline styles. */
.menu-hero {
text-align: center;
}
.menu-hero-title {
font-size: clamp(2.4rem, 5vw, 3.8rem);
color: var(--gold-bright);
margin-top: 0.5rem;
}
.menu-hero-rule {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.menu-hero-intro {
max-width: 52ch;
margin: 0 auto;
font-size: 1.05rem;
}
.menu-page-container {
max-width: 62rem;
}
.menu-grid-flush {
margin-top: 0;
}
.menu-empty {
text-align: center;
padding: 3rem 1.5rem;
}
.menu-disclaimer {
margin-top: 3.5rem;
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--rule);
}
.menu-disclaimer-text {
font-size: 0.8rem;
line-height: 1.6;
max-width: 68ch;
margin: 0 auto;
}
.menu-disclaimer-ctas {
margin-top: 2rem;
justify-content: center;
}
+1
View File
@@ -31,4 +31,5 @@
@use "pages/admin-login"; @use "pages/admin-login";
@use "pages/admin-menu"; @use "pages/admin-menu";
@use "pages/admin-hours"; @use "pages/admin-hours";
@use "pages/admin-announcements";
@use "pages/admin-account"; @use "pages/admin-account";
+95
View File
@@ -1933,9 +1933,65 @@ footer a.email:hover {
} }
.menu-page-content { .menu-page-content {
padding-top: 0;
padding-bottom: 5.5rem; padding-bottom: 5.5rem;
} }
/* Formerly inline style="" attributes on menu.php — moved here so the
CSP can forbid inline styles. */
.menu-hero {
text-align: center;
}
.menu-hero-title {
font-size: clamp(2.4rem, 5vw, 3.8rem);
color: var(--gold-bright);
margin-top: 0.5rem;
}
.menu-hero-rule {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.menu-hero-intro {
max-width: 52ch;
margin: 0 auto;
font-size: 1.05rem;
}
.menu-page-container {
max-width: 62rem;
}
.menu-grid-flush {
margin-top: 0;
}
.menu-empty {
text-align: center;
padding: 3rem 1.5rem;
}
.menu-disclaimer {
margin-top: 3.5rem;
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--rule);
}
.menu-disclaimer-text {
font-size: 0.8rem;
line-height: 1.6;
max-width: 68ch;
margin: 0 auto;
}
.menu-disclaimer-ctas {
margin-top: 2rem;
justify-content: center;
}
/* ---------- admin / management styles ---------- */ /* ---------- admin / management styles ---------- */
.admin-login-body { .admin-login-body {
min-height: 100vh; min-height: 100vh;
@@ -2196,6 +2252,45 @@ footer a.email:hover {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Formerly inline style="" attributes on admin/hours.php. */
.hours-note {
margin-top: 1.25rem;
}
.hours-preview-intro {
margin-bottom: 1.25rem;
}
/* ---------- admin: announcements ---------- */
/* Formerly inline style="" attributes on admin/index.php — moved here so
the CSP can forbid inline styles. */
.ann-summary {
font-size: 0.82rem;
margin-top: 0.2rem;
max-width: 32ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ann-schedule {
font-size: 0.8rem;
color: var(--ink-muted);
}
.ann-placements {
font-size: 0.82rem;
color: var(--ink-muted);
}
.ann-field-hint {
font-size: 0.78rem;
}
.inline-form {
display: inline;
}
/* ---------- admin: my account ---------- */ /* ---------- admin: my account ---------- */
/* One panel, fields in the shared two-column .form-grid (which already /* One panel, fields in the shared two-column .form-grid (which already
stacks below 47.5em). Capped so the fields don't stretch across the stacks below 47.5em). Capped so the fields don't stretch across the