Compare commits

..
12 Commits
Author SHA1 Message Date
David Dyer 51bdfafaed Remove Docker CLI installation step from GitHub Actions workflow
Build image / build (push) Successful in 10s
2026-10-27 22:16:53 -04:00
David Dyer 3a77bae21e Change build environment from debian to ubuntu-latest in GitHub Actions workflow
Build image / build (push) Successful in 1m29s
2026-10-27 22:12:39 -04:00
David Dyer 69be043f9d Add deployment step to GitHub Actions workflow for Docker host
Build image / build (push) Failing after 32s
2026-10-25 19:45:30 -04:00
David Dyer a7fb530061 Add step to trust home CA for improved security in Docker builds 2026-10-25 19:09:34 -04:00
David Dyer cf9fed749b Install Docker CLI in build workflow for Debian environment 2026-10-25 17:35:25 -04:00
David Dyer 7cac2ebcae Change build environment from ubuntu-latest to debian for consistency in workflow 2026-10-25 17:26:32 -04:00
David Dyer e2c47bed61 Add GitHub Actions workflow for building and pushing Docker images 2026-10-25 17:20:51 -04:00
David Dyer 759c22afaf Add Dockerfile and .dockerignore for improved containerization and security 2026-10-25 17:09:05 -04:00
David Dyer bb65ecd43c Refactor configuration handling: implement dynamic loading of config and cache paths for improved security and flexibility in Docker environments 2026-10-25 16:49:43 -04:00
David Dyer c7b836bcf2 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.
2026-10-25 16:06:15 -04:00
David Dyer 3286bce6b1 Enhance Docker setup and local development: bind database directory, update PHP server command, and add local dev router to prevent exposure of sensitive files 2026-10-25 14:38:09 -04:00
David Dyer 02551eebbc Refactor admin modals: replace backdrop divs with native <dialog> elements for improved accessibility and functionality 2026-10-25 13:08:40 -04:00
32 changed files with 799 additions and 347 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Secrets and runtime state — never in the build context.
config.php
db/fitz.sqlite*
cache/
# Rebuilt inside the image.
node_modules/
dist/
styles.css
styles.css.map
# Dev-only.
.git/
.claude/
.DS_Store
.hintrc
dev.sh
+66
View File
@@ -0,0 +1,66 @@
# Build the site image and push it to this Gitea instance's container
# registry. Registry host and owner come from the instance itself, so the
# same file works on the home Gitea and the VPS Gitea without edits.
#
# Needs one repo secret: REGISTRY_TOKEN, a personal access token for the
# repo owner with package read/write scope.
name: Build image
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
SERVER_URL: ${{ gitea.server_url }}
OWNER: ${{ gitea.repository_owner }}
REPO: ${{ gitea.repository }}
SHA: ${{ gitea.sha }}
steps:
# Trust the home CA when one is configured: instance variable HOME_CA
# holds the root cert as PEM. Leave it unset on hosts with a public
# cert (the VPS) and this step is skipped.
- name: Trust home CA
if: ${{ vars.HOME_CA != '' }}
env:
HOME_CA: ${{ vars.HOME_CA }}
run: |
printf '%s\n' "$HOME_CA" > /usr/local/share/ca-certificates/home-ca.crt
update-ca-certificates
- uses: actions/checkout@v4
env:
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
- name: Log in to registry
run: |
REGISTRY="${SERVER_URL#https://}"
echo "REGISTRY=$REGISTRY" >> "$GITHUB_ENV"
echo "IMAGE=$REGISTRY/${REPO,,}" >> "$GITHUB_ENV"
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "$OWNER" --password-stdin
- name: Build
run: docker build -t "$IMAGE:latest" -t "$IMAGE:${SHA::12}" .
- name: Push
run: docker push --all-tags "$IMAGE"
# Redeploy on the Docker host. The key's forced command on the host
# only accepts allowlisted site names, so this can't run anything else.
# Skipped when DEPLOY_HOST isn't set.
- name: Deploy
if: ${{ vars.DEPLOY_HOST != '' }}
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes "root@$DEPLOY_HOST" theoldfitz
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
# Build stage: compile Sass and assemble the allowlisted dist/ folder.
# config.php never enters the image (.dockerignore), and build-dist.js
# doesn't copy it anyway.
FROM node:24-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run dist
# Runtime stage. Production php.ini and expose_php = Off are baked in here,
# replacing the startup `command:` in docker-compose.yaml. Files are owned
# by root with 644/755 (normalized by build-dist.js), so www-data can read
# the site but not modify it; data/ and config/ arrive as mounts.
FROM php:8.5-apache
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
&& printf 'expose_php = Off\n' > "$PHP_INI_DIR/conf.d/zz-hardening.ini"
COPY --from=build /src/dist/ /var/www/html/
+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">
+11
View File
@@ -42,3 +42,14 @@ document.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
} }
}); });
// Announcement editor dialog: the server flags it for ?new / ?edit URLs.
// Closing it any way (× link, Esc) drops the query string, so a reload
// doesn't reopen it.
const annModal = document.getElementById('modal-ann');
if (annModal?.hasAttribute('data-open-on-load')) {
annModal.showModal();
annModal.addEventListener('close', () => {
history.replaceState(null, '', 'index.php');
});
}
+15 -43
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'] ?>">
@@ -239,11 +213,10 @@ $announcements = get_all_announcements();
</div> </div>
</main> </main>
<!-- Modal Backdrop: Announcement Editor (Create / Edit) — server-rendered <!-- Modal: Announcement Editor (Create / Edit). The server marks it
open/closed via the is-open class, since this page has no JS file; data-open-on-load for ?new / ?edit URLs and index.js opens it with
the underlying save is still a plain form POST + reload, unchanged. --> showModal(); saving is still a plain form POST + reload. -->
<div class="admin-modal-backdrop<?= ($isCreatingNew || $editItem) ? ' is-open' : '' ?>" role="dialog" aria-modal="true" aria-labelledby="modal-ann-title"> <dialog class="admin-modal" id="modal-ann" aria-labelledby="modal-ann-title"<?= ($isCreatingNew || $editItem) ? ' data-open-on-load' : '' ?>>
<div class="admin-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-ann-title"><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></h2> <h2 id="modal-ann-title"><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></h2>
<a href="index.php" class="modal-close" aria-label="Close dialog">&times;</a> <a href="index.php" class="modal-close" aria-label="Close dialog">&times;</a>
@@ -287,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">
@@ -317,8 +290,7 @@ $announcements = get_all_announcements();
<a href="index.php" class="btn btn-outline">Cancel</a> <a href="index.php" class="btn btn-outline">Cancel</a>
</div> </div>
</form> </form>
</div> </dialog>
</div>
<script src="index.js" defer></script> <script src="index.js" defer></script>
+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">
+15 -9
View File
@@ -5,8 +5,8 @@
const csrfToken = document.body.dataset.csrfToken; const csrfToken = document.body.dataset.csrfToken;
// Item Modal helpers // Item Modal helpers
const itemModal = document.getElementById('modal-item-backdrop'); const itemModal = document.getElementById('modal-item');
const catModal = document.getElementById('modal-cat-backdrop'); const catModal = document.getElementById('modal-cat');
function openItemModal(itemData = null) { function openItemModal(itemData = null) {
const title = document.getElementById('modal-item-title'); const title = document.getElementById('modal-item-title');
@@ -37,12 +37,12 @@ function openItemModal(itemData = null) {
availField.checked = true; availField.checked = true;
} }
itemModal.classList.add('is-open'); itemModal.showModal();
nameField.focus(); nameField.focus();
} }
function closeItemModal() { function closeItemModal() {
itemModal.classList.remove('is-open'); itemModal.close();
} }
function openCategoryModal(catData = null) { function openCategoryModal(catData = null) {
@@ -63,18 +63,24 @@ function openCategoryModal(catData = null) {
descField.value = ''; descField.value = '';
} }
catModal.classList.add('is-open'); catModal.showModal();
nameField.focus(); nameField.focus();
} }
function closeCategoryModal() { function closeCategoryModal() {
catModal.classList.remove('is-open'); 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) {
+10 -40
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">
@@ -328,9 +302,8 @@ $menuEnabled = is_menu_enabled();
</div> </div>
</main> </main>
<!-- Modal Backdrop: Menu Item --> <!-- Modal: Menu Item -->
<div class="admin-modal-backdrop" id="modal-item-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-item-title"> <dialog class="admin-modal" id="modal-item" aria-labelledby="modal-item-title">
<div class="admin-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-item-title">✨ Add Menu Item</h2> <h2 id="modal-item-title">✨ Add Menu Item</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-item-modal">&times;</button> <button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-item-modal">&times;</button>
@@ -383,12 +356,10 @@ $menuEnabled = is_menu_enabled();
<button type="button" class="btn btn-outline" id="btn-cancel-item">Cancel</button> <button type="button" class="btn btn-outline" id="btn-cancel-item">Cancel</button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
<!-- Modal Backdrop: Category --> <!-- Modal: Category -->
<div class="admin-modal-backdrop" id="modal-cat-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-cat-title"> <dialog class="admin-modal" id="modal-cat" aria-labelledby="modal-cat-title">
<div class="admin-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-cat-title">✨ Add Category</h2> <h2 id="modal-cat-title">✨ Add Category</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-cat-modal">&times;</button> <button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-cat-modal">&times;</button>
@@ -415,8 +386,7 @@ $menuEnabled = is_menu_enabled();
<button type="button" class="btn btn-outline" id="btn-cancel-cat">Cancel</button> <button type="button" class="btn btn-outline" id="btn-cancel-cat">Cancel</button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
<script src="menu.js" defer></script> <script src="menu.js" defer></script>
+19 -15
View File
@@ -5,14 +5,20 @@
const csrfToken = document.body.dataset.csrfToken; const csrfToken = document.body.dataset.csrfToken;
const currentUserId = document.body.dataset.currentUserId; const currentUserId = document.body.dataset.currentUserId;
const userModal = document.getElementById('modal-user-backdrop'); const userModal = document.getElementById('modal-user');
const passwordModal = document.getElementById('modal-password-backdrop'); const passwordModal = document.getElementById('modal-password');
const editModal = document.getElementById('modal-edit-backdrop'); 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) {
@@ -54,16 +60,14 @@ function openUserModal() {
const form = document.getElementById('form-user'); const form = document.getElementById('form-user');
form.reset(); form.reset();
resetPasswordVisibility(form); resetPasswordVisibility(form);
userModal.classList.add('is-open'); userModal.showModal();
} }
// Safari ignores autocomplete="off" on fields labeled "Username" and offers // Native <dialog>: close() hides it (not rendered, so its fields can't keep
// saved logins whenever one has focus. The modals used to auto-focus that // focus or trigger autofill) and returns focus to whatever opened it.
// field, and a closed modal left it focused, so the prompt kept popping up. // Esc closes it too, with no extra code.
// Now nothing is auto-focused, and closing a modal drops focus.
function closeModal(modal) { function closeModal(modal) {
modal.classList.remove('is-open'); modal.close();
if (modal.contains(document.activeElement)) document.activeElement.blur();
} }
function closeUserModal() { function closeUserModal() {
@@ -84,7 +88,7 @@ function openPasswordModal(userId, username) {
document.getElementById('password-current-group').style.display = isSelf ? '' : 'none'; document.getElementById('password-current-group').style.display = isSelf ? '' : 'none';
passwordModal.classList.add('is-open'); passwordModal.showModal();
(isSelf ? document.getElementById('password-field-current') : document.getElementById('password-field-new')).focus(); (isSelf ? document.getElementById('password-field-current') : document.getElementById('password-field-new')).focus();
} }
@@ -105,7 +109,7 @@ function openEditModal(userId, username, role) {
roleSelect.disabled = isSelf; roleSelect.disabled = isSelf;
document.getElementById('edit-role-self-hint').hidden = !isSelf; document.getElementById('edit-role-self-hint').hidden = !isSelf;
editModal.classList.add('is-open'); editModal.showModal();
} }
function closeEditModal() { function closeEditModal() {
+13 -43
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">
@@ -302,9 +278,8 @@ $users = get_all_users();
</div> </div>
</main> </main>
<!-- Modal Backdrop: Add User --> <!-- Modal: Add User -->
<div class="admin-modal-backdrop" id="modal-user-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-user-title"> <dialog class="admin-modal" id="modal-user" aria-labelledby="modal-user-title">
<div class="admin-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-user-title">✨ Add User</h2> <h2 id="modal-user-title">✨ Add User</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-user-modal">&times;</button> <button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-user-modal">&times;</button>
@@ -361,12 +336,10 @@ $users = get_all_users();
<button type="button" class="btn btn-outline" id="btn-cancel-user">Cancel</button> <button type="button" class="btn btn-outline" id="btn-cancel-user">Cancel</button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
<!-- Modal Backdrop: Edit User --> <!-- Modal: Edit User -->
<div class="admin-modal-backdrop" id="modal-edit-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title"> <dialog class="admin-modal" id="modal-edit" aria-labelledby="modal-edit-title">
<div class="admin-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-edit-title">✏️ Edit User</h2> <h2 id="modal-edit-title">✏️ Edit User</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-edit-modal">&times;</button> <button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-edit-modal">&times;</button>
@@ -398,12 +371,10 @@ $users = get_all_users();
<button type="button" class="btn btn-outline" id="btn-cancel-edit">Cancel</button> <button type="button" class="btn btn-outline" id="btn-cancel-edit">Cancel</button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
<!-- Modal Backdrop: Change Password --> <!-- Modal: Change Password -->
<div class="admin-modal-backdrop" id="modal-password-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-password-title"> <dialog class="admin-modal" id="modal-password" aria-labelledby="modal-password-title">
<div class="admin-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-password-title">🔑 Change Password</h2> <h2 id="modal-password-title">🔑 Change Password</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-password-modal">&times;</button> <button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-password-modal">&times;</button>
@@ -459,8 +430,7 @@ $users = get_all_users();
<button type="button" class="btn btn-outline" id="btn-cancel-password">Cancel</button> <button type="button" class="btn btn-outline" id="btn-cancel-password">Cancel</button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
<script src="users.js" defer></script> <script src="users.js" defer></script>
+52 -32
View File
@@ -1,45 +1,65 @@
# The Old Fitz — compose for any Docker host (test or production).
#
# The site itself is baked into the image (see Dockerfile); the host only
# holds state. Expected layout, one folder per site:
# theoldfitz/
# docker-compose.yaml (this file)
# .env (FITZ_IMAGE, and PROXY_NETWORK if not "proxy")
# config/config.php (live secrets — never in the image)
# data/ (fitz.sqlite + cache/ — never in the image)
#
# Deploy: `docker compose pull && docker compose up -d`.
# Manual fallback from a checkout of the repo: `docker compose up -d --build`.
#
# First run: create data/ and `chown 33:33 data` (www-data in the image).
# Copy config.example.php to config/config.php, fill it in, and make it
# readable by www-data (`chown root:33` + `chmod 640`). 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 # Servers set FITZ_IMAGE in .env to the registry image, e.g.
# gitea.example.com/david/the-old-fitz:latest. Unset, it falls back to a
# local tag so `up --build` works from a checkout.
image: ${FITZ_IMAGE:-the-old-fitz:local}
build: .
container_name: the-old-fitz container_name: the-old-fitz
restart: unless-stopped restart: unless-stopped
networks: networks:
- nginxproxymanager - proxy
volumes: volumes:
# Database and cache live outside the web root so Apache can't serve
# them, and outside the image so deploys never touch them.
- type: bind - type: bind
source: /DATA/AppData/theoldfitz/www source: ./data
target: /var/www/html target: /var/www/data
# Config is mounted as a directory, not a single file: a single-file
# bind mount pins the inode, so an editor that saves by replacing the
# file leaves the container reading the old copy until a restart.
- type: bind
source: ./config
target: /var/www/config
read_only: true
environment: environment:
- TZ=America/Indiana/Indianapolis - TZ=America/Indiana/Indianapolis
labels: - FITZ_DB_PATH=/var/www/data/fitz.sqlite
icon: https://www.php.net/images/logos/php-logo.svg - FITZ_CACHE_DIR=/var/www/data/cache
- FITZ_CONFIG_PATH=/var/www/config/config.php
x-casaos: # Cap container logs so PHP's error log can't slowly fill the disk.
architectures: logging:
- amd64 driver: json-file
- arm64 options:
author: self max-size: 10m
category: Developer max-file: "3"
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
+5 -2
View File
@@ -11,12 +11,15 @@ function get_db(): PDO {
return $pdo; return $pdo;
} }
$dbDir = __DIR__ . '/../db'; // In Docker, FITZ_DB_PATH points outside the web root (see
// docker-compose.yaml), so Apache can never serve the file. Local dev
// falls back to db/ in the project, which the dev router refuses to serve.
$dbPath = getenv('FITZ_DB_PATH') ?: __DIR__ . '/../db/fitz.sqlite';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) { if (!is_dir($dbDir)) {
mkdir($dbDir, 0755, true); mkdir($dbDir, 0755, true);
} }
$dbPath = $dbDir . '/fitz.sqlite';
$isNew = !file_exists($dbPath); $isNew = !file_exists($dbPath);
$pdo = new PDO('sqlite:' . $dbPath, null, null, [ $pdo = new PDO('sqlite:' . $dbPath, null, null, [
+5 -2
View File
@@ -20,7 +20,11 @@ function fritz_google_reviews(): array {
return $fallback; return $fallback;
} }
$cacheFile = __DIR__ . '/../cache/google-reviews.json'; // In Docker, FITZ_CACHE_DIR points into the writable data/ mount, outside
// the web root, so deploys don't wipe it and www/ can stay read-only to
// www-data. Local dev falls back to cache/ in the project root.
$cacheDir = getenv('FITZ_CACHE_DIR') ?: __DIR__ . '/../cache';
$cacheFile = $cacheDir . '/google-reviews.json';
if (is_file($cacheFile)) { if (is_file($cacheFile)) {
$cached = json_decode((string) file_get_contents($cacheFile), true); $cached = json_decode((string) file_get_contents($cacheFile), true);
@@ -33,7 +37,6 @@ function fritz_google_reviews(): array {
$fresh = fritz_fetch_google_reviews($fallback); $fresh = fritz_fetch_google_reviews($fallback);
$fresh['fetched_at'] = time(); $fresh['fetched_at'] = time();
$cacheDir = dirname($cacheFile);
if (!is_dir($cacheDir)) { if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true); @mkdir($cacheDir, 0775, true);
} }
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
/**
* Loads the site config. In Docker, FITZ_CONFIG_PATH points outside the
* web root (see docker-compose.yaml), so a deploy that replaces www/ can
* never overwrite or ship it. Local dev falls back to config.php in the
* project root.
*/
require getenv('FITZ_CONFIG_PATH') ?: __DIR__ . '/../config.php';
+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');
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
<?php <?php
require __DIR__ . '/config.php'; require __DIR__ . '/inc/load-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';
+14 -12
View File
@@ -1,8 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/config.php'; require __DIR__ . '/inc/load-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>
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"", "dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"",
"dev:php": "php -S localhost:8000", "dev:php": "php -S localhost:8000 scripts/dev-router.php",
"dev:sass": "sass --watch --source-map src/scss/styles.scss:styles.css", "dev:sass": "sass --watch --source-map src/scss/styles.scss:styles.css",
"dev:browser": "browser-sync start --proxy http://localhost:8000 --files \"*.php,admin/**/*.php,inc/**/*.php,main.js,styles.css\" --no-open --no-ui", "dev:browser": "browser-sync start --proxy http://localhost:8000 --files \"*.php,admin/**/*.php,inc/**/*.php,main.js,styles.css\" --no-open --no-ui",
"build:sass": "sass --no-source-map src/scss/styles.scss:styles.css", "build:sass": "sass --no-source-map src/scss/styles.scss:styles.css",
+27 -4
View File
@@ -6,9 +6,14 @@
* *
* This is an allowlist by design: only the files and folders listed below * This is an allowlist by design: only the files and folders listed below
* are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/, * are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/,
* .claude/, docker-compose.yaml, dev.sh, .hintrc, config.example.php, the * .claude/, docker-compose.yaml, dev.sh, .hintrc, config.php,
* live db/fitz.sqlite, ...) never ends up in dist/ — including anything * config.example.php, the live db/fitz.sqlite, ...) never ends up in
* added later, since it has to be added here explicitly first. * dist/ — including anything added later, since it has to be added here
* explicitly first.
*
* config.php is deliberately excluded: it holds live secrets and lives
* outside the web root on the server (see FITZ_CONFIG_PATH in
* docker-compose.yaml), so a deploy can never overwrite it.
* *
* Run with: npm run dist * Run with: npm run dist
*/ */
@@ -25,7 +30,6 @@ const FILES = [
'main.js', 'main.js',
'styles.css', 'styles.css',
'robots.txt', 'robots.txt',
'config.php', // holds the live FB_PAGE_ACCESS_TOKEN — see README before sharing dist/ anywhere but the server
]; ];
const DIRS = ['admin', 'assets', 'bin', 'inc']; const DIRS = ['admin', 'assets', 'bin', 'inc'];
@@ -74,6 +78,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 '));
} }
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* Router for local dev (`php -S localhost:8000 scripts/dev-router.php`).
*
* PHP's built-in server ignores .htaccess, so without this it would serve
* the SQLite database, includes, and project files to anyone who can
* reach it — including other devices on the LAN via browser-sync.
* Anything not blocked falls through to the built-in server as normal.
*/
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
$path = preg_replace('#/+#', '/', $path);
// Case-insensitive: macOS's filesystem is, so /DB/fitz.sqlite would match.
$blocked = '#^/(?:db|inc|bin|scripts|src|dist|node_modules)(?:/|$)'
. '|(?:^|/)\.'
. '|^/(?:package(?:-lock)?\.json|docker-compose\.ya?ml|config(?:\.example)?\.php)$#i';
if (preg_match($blocked, $path)) {
http_response_code(404);
echo 'Not Found';
return true;
}
// Apache (mod_dir) redirects /admin to /admin/; the built-in server just
// serves admin/index.php at /admin. Every relative link on that page then
// resolves against / instead of /admin/ — "menu.php" lands on the public
// menu, "users.php" 404s. Match Apache so dev behaves like production.
if ($path !== '/' && !str_ends_with($path, '/') && is_dir(__DIR__ . '/..' . $path)) {
$query = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY);
header('Location: ' . $path . '/' . ($query ? '?' . $query : ''), true, 301);
return true;
}
return false;
+43 -30
View File
@@ -1,49 +1,62 @@
/* ---------- admin modal dialogs ---------- */ /* ---------- admin modal dialogs ---------- */
.admin-modal-backdrop { /* Native <dialog> modals. The browser handles the overlay (::backdrop),
position: fixed; focus trapping, inertness of the page behind, and Esc. A closed dialog
inset: 0; is display: none, so its fields can't hold focus or trigger autofill.
background: rgba(15, 12, 10, 0.8); Open/close fades rely on @starting-style for the entry and
backdrop-filter: blur(0.25rem); allow-discrete to keep display and overlay alive through the exit. */
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
opacity: 0;
pointer-events: none;
// Closed modals must be visibility: hidden, not just transparent —
// otherwise their fields stay focusable and Safari offers saved logins
// for an invisible Username field. The visibility switch waits for the
// fade-out to finish so the close animation still plays.
visibility: hidden;
transition: opacity .2s ease, visibility 0s linear .2s;
}
.admin-modal-backdrop.is-open {
opacity: 1;
pointer-events: auto;
visibility: visible;
transition: opacity .2s ease, visibility 0s;
}
.admin-modal { .admin-modal {
background: var(--surface); background: var(--surface);
color: inherit;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 0.25rem; border-radius: 0.25rem;
width: 100%; width: calc(100% - 3rem);
max-width: 36rem; max-width: 36rem;
max-height: 90vh; max-height: 90vh;
margin: auto;
overflow-y: auto; overflow-y: auto;
padding: 2rem; padding: 2rem;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6); box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(0.75rem); transform: translateY(0.75rem);
transition: transform .2s ease; transition:
opacity .2s ease,
transform .2s ease,
overlay .2s ease allow-discrete,
display .2s ease allow-discrete;
} }
.admin-modal-backdrop.is-open .admin-modal { .admin-modal[open] {
opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
.admin-modal::backdrop {
background-color: rgba(15, 12, 10, 0);
backdrop-filter: blur(0);
transition:
background-color .2s ease,
backdrop-filter .2s ease,
overlay .2s ease allow-discrete,
display .2s ease allow-discrete;
}
.admin-modal[open]::backdrop {
background-color: rgba(15, 12, 10, 0.8);
backdrop-filter: blur(0.25rem);
}
@starting-style {
.admin-modal[open] {
opacity: 0;
transform: translateY(0.75rem);
}
.admin-modal[open]::backdrop {
background-color: rgba(15, 12, 10, 0);
backdrop-filter: blur(0);
}
}
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+24
View File
@@ -46,6 +46,30 @@ textarea.form-control {
line-height: 1.5; line-height: 1.5;
} }
/* Selects: drop the native (rounded, shorter) control so they match the
text inputs, and draw the chevron with two gradients instead of an
SVG data URI — it can use the theme tokens and needs no `data:` in the
CSP's img-src. color-scheme gives the native option list dark colors. */
select.form-control {
appearance: none;
color-scheme: dark;
cursor: pointer;
padding-right: 2.5rem;
background-image:
linear-gradient(45deg, transparent 50%, var(--gold) 50%),
linear-gradient(135deg, var(--gold) 50%, transparent 50%);
background-position:
calc(100% - 1.2rem) 50%,
calc(100% - 0.85rem) 50%;
background-size: 0.35rem 0.35rem;
background-repeat: no-repeat;
}
select.form-control:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.error-msg, .error-msg,
.alert-error { .alert-error {
background: var(--danger-bg); background: var(--danger-bg);
+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";
+148 -26
View File
@@ -785,47 +785,54 @@ button.status-badge:disabled {
} }
} }
/* ---------- admin modal dialogs ---------- */ /* ---------- admin modal dialogs ---------- */
.admin-modal-backdrop { /* Native <dialog> modals. The browser handles the overlay (::backdrop),
position: fixed; focus trapping, inertness of the page behind, and Esc. A closed dialog
inset: 0; is display: none, so its fields can't hold focus or trigger autofill.
background: rgba(15, 12, 10, 0.8); Open/close fades rely on @starting-style for the entry and
backdrop-filter: blur(0.25rem); allow-discrete to keep display and overlay alive through the exit. */
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
opacity: 0;
pointer-events: none;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}
.admin-modal-backdrop.is-open {
opacity: 1;
pointer-events: auto;
visibility: visible;
transition: opacity 0.2s ease, visibility 0s;
}
.admin-modal { .admin-modal {
background: var(--surface); background: var(--surface);
color: inherit;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 0.25rem; border-radius: 0.25rem;
width: 100%; width: calc(100% - 3rem);
max-width: 36rem; max-width: 36rem;
max-height: 90vh; max-height: 90vh;
margin: auto;
overflow-y: auto; overflow-y: auto;
padding: 2rem; padding: 2rem;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6); box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(0.75rem); transform: translateY(0.75rem);
transition: transform 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease, overlay 0.2s ease allow-discrete, display 0.2s ease allow-discrete;
} }
.admin-modal-backdrop.is-open .admin-modal { .admin-modal[open] {
opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
.admin-modal::backdrop {
background-color: rgba(15, 12, 10, 0);
backdrop-filter: blur(0);
transition: background-color 0.2s ease, backdrop-filter 0.2s ease, overlay 0.2s ease allow-discrete, display 0.2s ease allow-discrete;
}
.admin-modal[open]::backdrop {
background-color: rgba(15, 12, 10, 0.8);
backdrop-filter: blur(0.25rem);
}
@starting-style {
.admin-modal[open] {
opacity: 0;
transform: translateY(0.75rem);
}
.admin-modal[open]::backdrop {
background-color: rgba(15, 12, 10, 0);
backdrop-filter: blur(0);
}
}
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -905,6 +912,26 @@ textarea.form-control {
line-height: 1.5; line-height: 1.5;
} }
/* Selects: drop the native (rounded, shorter) control so they match the
text inputs, and draw the chevron with two gradients instead of an
SVG data URI — it can use the theme tokens and needs no `data:` in the
CSP's img-src. color-scheme gives the native option list dark colors. */
select.form-control {
appearance: none;
color-scheme: dark;
cursor: pointer;
padding-right: 2.5rem;
background-image: linear-gradient(45deg, transparent 50%, var(--gold) 50%), linear-gradient(135deg, var(--gold) 50%, transparent 50%);
background-position: calc(100% - 1.2rem) 50%, calc(100% - 0.85rem) 50%;
background-size: 0.35rem 0.35rem;
background-repeat: no-repeat;
}
select.form-control:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.error-msg, .error-msg,
.alert-error { .alert-error {
background: var(--danger-bg); background: var(--danger-bg);
@@ -1906,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;
@@ -2169,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