23 Commits

Author SHA1 Message Date
steve bc8d4f9fe5 fix(github) bumped trivy to latest version 2026-03-28 23:29:00 -06:00
steve aa7fefe497 ci: scan Docker image for CRITICAL/HIGH CVEs with Trivy
Runs aquasecurity/trivy-action after the build step and fails the
workflow if any CRITICAL or HIGH severity vulnerabilities are found,
blocking the push to Docker Hub.
2026-03-28 23:23:54 -06:00
steve 898441af0c fix: add set -e and gunicorn worker timeout to entrypoint
set -e ensures the script aborts on any error (e.g. failed chown)
rather than silently continuing. --timeout 30 kills hung workers to
prevent slow-client attacks from exhausting the worker pool.
2026-03-28 23:23:53 -06:00
steve 617aa5f028 fix: enforce max input lengths on guestbook form
Adds FIELD_MAX constants and server-side length checks in the index
route. Adds matching maxlength attributes on all form inputs so the
browser enforces limits before submission.
2026-03-28 23:23:53 -06:00
steve ecdcc044b7 feat: add CSRF protection to all POST forms
Installs Flask-WTF and enables CSRFProtect globally. Adds csrf_token
hidden fields to all four POST forms (login, delete entry, add user,
delete user, and the public guestbook form). Exempts the API endpoint
which uses header-based key auth instead.
2026-03-28 23:23:53 -06:00
steve 9ad7128619 feat: add security headers, session hardening, and admin cache control
Sets X-Content-Type-Options, X-Frame-Options, and Referrer-Policy on
all responses. Prevents browsers from caching admin pages. Configures
session cookies as HttpOnly and SameSite=Lax with an 8-hour lifetime.
2026-03-28 23:23:53 -06:00
steve 61a298a735 fix: rate-limit admin login and API endpoint
Limits POST to /admin/login to 10 requests/minute to block brute-force
attacks. Limits GET /api/guests to 100 requests/hour to prevent bulk
data exfiltration.
2026-03-28 23:23:52 -06:00
steve 4d58e0f0a1 fix: abort startup if SECRET_KEY is not set
Raises RuntimeError at startup instead of silently falling back to a
hardcoded default, preventing misconfigured deployments from running
with a publicly-known session key.
2026-03-28 23:23:52 -06:00
steve 53741a4cbf Merge pull request #25 from tmdinosaurcenter/dependabot/docker/python-3.14-slim
Bump python from 3.9-slim to 3.14-slim
2026-03-28 23:09:34 -06:00
steve 4c691ab31a Merge pull request #24 from tmdinosaurcenter/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 2 to 4
2026-03-28 23:09:32 -06:00
steve 77c377ab51 Merge pull request #23 from tmdinosaurcenter/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-03-28 23:09:24 -06:00
steve ae5002d407 Merge pull request #22 from tmdinosaurcenter/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 2 to 4
2026-03-28 23:09:21 -06:00
dependabot[bot] 5f71641cf0 Bump python from 3.9-slim to 3.14-slim
Bumps python from 3.9-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:05:28 +00:00
dependabot[bot] c1206a244c Bump docker/setup-buildx-action from 2 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:04:59 +00:00
dependabot[bot] 8230ae1c1c Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:04:55 +00:00
dependabot[bot] c55037b37b Bump docker/login-action from 2 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:04:51 +00:00
steve 36f8a01999 ci: add Dependabot config and issue templates
Add weekly Dependabot updates for pip, Docker, and GitHub Actions.
Add issue templates for bug reports, feature requests, documentation,
and general feedback.
2026-03-28 23:04:13 -06:00
steve 4f675fe74c feat: display admin timestamps in America/Denver time
Convert UTC timestamps from SQLite to Mountain Time (America/Denver)
using a Jinja2 template filter backed by zoneinfo; add tzdata dependency
for IANA timezone data in the slim Docker image.
2026-03-28 22:58:37 -06:00
steve d5eac47ceb feat: apply TMDC brand fonts to guestbook page
Use Vollkorn 700 for headings and Open Sans for body text,
in line with The Montana Dinosaur Center style guide.
2026-03-11 18:05:08 -06:00
steve 9ebac80f35 feat: add webhook integration for new guestbook submissions
Posts signup data as JSON to WEBHOOK_URL (e.g. an n8n Webhook node)
in a daemon thread so it never blocks the visitor-facing response.
2026-03-11 15:30:31 -06:00
steve 2d4eac6583 refactor: migrate admin auth from HTTP Basic to Flask-Login sessions
Replaces browser-cached Basic Auth credentials with proper server-side
session management. Logout now fully invalidates the session. Adds an
HTML login form at /admin/login, SECRET_KEY env var support, and updates
README with key generation instructions and role table.
2026-03-10 11:41:16 -06:00
steve 94d6690e57 fix: add logout button to admin pages 2026-03-10 10:39:10 -06:00
steve 4f0a7df22a feat: add role-based access control with database-backed users 2026-03-10 10:29:42 -06:00
18 changed files with 635 additions and 43 deletions
+35
View File
@@ -0,0 +1,35 @@
---
name: Bug Report
about: Report a bug in the system
title: "[Bug]: "
labels: bug
assignees: ''
---
### Description
A clear and concise description of the bug.
### Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. See the error.
### Expected Behavior
Explain what you expected to happen.
### Screenshots
Add screenshots if applicable.
### Environment
- OS: [e.g., Windows, macOS, Linux]
- Browser: [e.g., Chrome, Firefox]
- Version: [e.g., 1.0.0]
### Additional Context
Add any other context about the problem here.
+22
View File
@@ -0,0 +1,22 @@
blank_issues_enabled: false
issue_templates:
- name: "Bug Report"
description: "Report a bug in the system."
title: "[Bug]: "
labels: ["bug"]
body: "./ISSUE_TEMPLATE/bug_report.md"
- name: "Feature Request"
description: "Propose a new feature or improvement."
title: "[Feature]: "
labels: ["enhancement"]
body: "./ISSUE_TEMPLATE/feature_request.md"
- name: "Documentation"
description: "Suggest updates or additions to the documentation."
title: "[Docs]: "
labels: ["documentation"]
body: "./ISSUE_TEMPLATE/documentation.md"
- name: "General Report"
description: "Provide general feedback or inquiries."
title: "[General]: "
labels: ["general"]
body: "./ISSUE_TEMPLATE/general_report.md"
+23
View File
@@ -0,0 +1,23 @@
--
name: Documentation
about: Suggest updates or additions to documentation
title: "[Docs]: "
labels: documentation
assignees: ''
---
### Documentation Update
What part of the documentation needs to be updated or added?
### Why Is This Needed?
Explain the importance of this update.
### Suggested Changes
Provide a detailed description of the changes.
### Additional Context
Include any related resources.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: "[Feature]: "
labels: enhancement
assignees: ''
---
### Feature Description
What feature would you like to see?
### Why Is This Needed?
Explain the problem or need for this feature.
### Suggested Solutions
Describe how this feature could be implemented.
### Additional Context
Add any relevant screenshots, links, or resources.
+15
View File
@@ -0,0 +1,15 @@
---
name: General Report
about: Provide general feedback or inquiries
title: "[General]: "
labels: general
assignees: ''
---
### Feedback or Inquiry
Provide your feedback or inquiry.
### Additional Information
Add any other relevant details here.
+33
View File
@@ -0,0 +1,33 @@
# Dependabot version updates
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
# Python dependencies — requirements.txt
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
# Docker base image — Dockerfile
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
# GitHub Actions workflow dependencies
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
- "ci"
+10 -3
View File
@@ -9,12 +9,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub - name: Log in to DockerHub
if: github.event_name == 'push' if: github.event_name == 'push'
uses: docker/login-action@v2 uses: docker/login-action@v4
with: with:
username: ${{ vars.DOCKER_USERNAME }} username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -25,6 +25,13 @@ jobs:
docker build . --file Dockerfile --tag $IMAGE_TAG docker build . --file Dockerfile --tag $IMAGE_TAG
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# Uncomment below to push the image to Docker Hub (or another registry) # Uncomment below to push the image to Docker Hub (or another registry)
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: ${{ env.IMAGE_TAG }}
format: table
exit-code: '1'
severity: CRITICAL,HIGH
- name: Push the Docker image - name: Push the Docker image
if: github.event_name == 'push' if: github.event_name == 'push'
run: | run: |
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
todo: todo:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: alstr/todo-to-issue-action@v5 - uses: alstr/todo-to-issue-action@v5
with: with:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,5 +1,5 @@
# Use a lightweight Python image # Use a lightweight Python image
FROM python:3.9-slim FROM python:3.14-slim
# Set the working directory # Set the working directory
WORKDIR /app WORKDIR /app
+38 -4
View File
@@ -98,9 +98,29 @@ Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (o
## Admin Interface ## Admin Interface
A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted. A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted. Authentication uses session cookies with an HTML login form — logging out fully invalidates the session so credentials are never cached by the browser.
Access requires `ADMIN_USER` and `ADMIN_PASSWORD` to be set in your `.env`. If either variable is missing, the admin interface will return a 503 error rather than allowing access with blank credentials. Access requires `ADMIN_USER`, `ADMIN_PASSWORD`, and `SECRET_KEY` to be set in your `.env`. If either of the admin credentials are missing the interface returns 503. If `SECRET_KEY` is not set a default development key is used, which is insecure in production — always set your own.
### Generating a `SECRET_KEY`
Use Python to generate a cryptographically random key:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Paste the output as the value for `SECRET_KEY` in your `.env`.
### User Roles
The bootstrap superadmin (set via `ADMIN_USER` / `ADMIN_PASSWORD`) can manage additional users at `/admin/users`:
| Role | View entries | Delete entries | Manage users |
| ---------- | :----------: | :------------: | :----------: |
| superadmin | ✓ | ✓ | ✓ |
| admin | ✓ | ✓ | — |
| viewer | ✓ | — | — |
## API Access ## API Access
@@ -112,14 +132,28 @@ Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-
## Upgrading ## Upgrading
When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables. As of v2.1.0, the following variables are required if you want to use the admin interface: When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables.
As of **v2.1.0**, the following variables are required for the admin interface:
```env ```env
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=changeme ADMIN_PASSWORD=changeme
``` ```
Replace the placeholder values with your own credentials before deploying. As of **v2.3.0**, a `SECRET_KEY` is also required for session-based authentication:
```env
SECRET_KEY=your-random-secret-key-here
```
Generate one with:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Replace all placeholder values with your own before deploying.
## Additional Notes ## Additional Notes
+276 -24
View File
@@ -1,12 +1,20 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort, Response
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from email_validator import validate_email, EmailNotValidError
from functools import wraps
import sqlite3
import logging import logging
import os import os
import re import re
import sqlite3
import threading
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from email_validator import validate_email, EmailNotValidError
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import (
LoginManager, UserMixin, login_user, logout_user, login_required, current_user
)
from flask_wtf.csrf import CSRFProtect
from werkzeug.security import generate_password_hash, check_password_hash
# Set up logging # Set up logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -14,7 +22,85 @@ logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db') DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
_secret_key = os.environ.get('SECRET_KEY')
if not _secret_key:
raise RuntimeError("SECRET_KEY environment variable must be set")
app.secret_key = _secret_key
limiter = Limiter(get_remote_address, app=app, default_limits=[]) limiter = Limiter(get_remote_address, app=app, default_limits=[])
csrf = CSRFProtect(app)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
PERMANENT_SESSION_LIFETIME=timedelta(hours=8),
)
@app.after_request
def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
if request.path.startswith('/admin'):
response.headers['Cache-Control'] = 'no-store'
return response
_DISPLAY_TZ = ZoneInfo('America/Denver')
@app.template_filter('localtime')
def localtime_filter(value):
if not value:
return value
try:
dt = datetime.strptime(str(value), '%Y-%m-%d %H:%M:%S')
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_DISPLAY_TZ).strftime('%Y-%m-%d %H:%M')
except ValueError:
return value
login_manager = LoginManager(app)
login_manager.login_view = 'admin_login'
# ---------------------------------------------------------------------------
# User model
# ---------------------------------------------------------------------------
class User(UserMixin):
"""Lightweight user object stored in the session."""
def __init__(self, user_id, username, role):
# user_id format: 's:<username>' for superadmin, 'u:<db_id>' for DB users
self.id = user_id
self.username = username
self.role = role
@login_manager.user_loader
def load_user(user_id):
if user_id.startswith('s:'):
username = user_id[2:]
admin_user = os.environ.get('ADMIN_USER')
if admin_user and username == admin_user:
return User(user_id, username, 'superadmin')
return None
if user_id.startswith('u:'):
db_id = user_id[2:]
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
row = c.execute(
'SELECT id, username, role FROM users WHERE id = ?', (db_id,)
).fetchone()
conn.close()
if row:
return User(f'u:{row[0]}', row[1], row[2])
except sqlite3.Error as e:
logger.error("Database error in user_loader: %s", e)
return None
# ---------------------------------------------------------------------------
# Profanity filter
# ---------------------------------------------------------------------------
def load_banned_words(): def load_banned_words():
banned_words = set() banned_words = set()
@@ -37,6 +123,14 @@ def load_banned_words():
BANNED_WORDS = load_banned_words() BANNED_WORDS = load_banned_words()
FIELD_MAX = {
'first_name': 100,
'last_name': 100,
'email': 254,
'location': 100,
'comment': 2000,
}
def contains_banned_words(text): def contains_banned_words(text):
lower = text.lower() lower = text.lower()
# Whole-word check (punctuation-stripped) — catches exact matches # Whole-word check (punctuation-stripped) — catches exact matches
@@ -52,6 +146,10 @@ def contains_banned_words(text):
return True return True
return False return False
# ---------------------------------------------------------------------------
# Database migrations
# ---------------------------------------------------------------------------
# Each entry is a list of SQL statements for that schema version. # Each entry is a list of SQL statements for that schema version.
# To add a column or index in the future, append a new list — never modify existing entries. # To add a column or index in the future, append a new list — never modify existing entries.
MIGRATIONS = [ MIGRATIONS = [
@@ -70,6 +168,15 @@ MIGRATIONS = [
'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)', 'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)',
'CREATE INDEX IF NOT EXISTS idx_guests_email ON guests (email)', 'CREATE INDEX IF NOT EXISTS idx_guests_email ON guests (email)',
], ],
# v2 — user accounts for admin interface (role: 'admin' or 'viewer')
[
'''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'viewer'))
)''',
],
] ]
def migrate_db(): def migrate_db():
@@ -111,6 +218,26 @@ def is_valid_email(email):
with app.app_context(): with app.app_context():
migrate_db() migrate_db()
# ---------------------------------------------------------------------------
# Webhook
# ---------------------------------------------------------------------------
def _fire_webhook(payload):
url = os.environ.get("WEBHOOK_URL", "")
if not url:
return
try:
import urllib.request, json as _json
data = _json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=5)
except Exception as e:
logger.warning("Webhook delivery failed: %s", e)
# ---------------------------------------------------------------------------
# Public routes
# ---------------------------------------------------------------------------
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@limiter.limit("5 per minute", methods=["POST"]) @limiter.limit("5 per minute", methods=["POST"])
def index(): def index():
@@ -127,6 +254,14 @@ def index():
if not (first_name and last_name and location): if not (first_name and last_name and location):
error = "First name, last name, and location are required." error = "First name, last name, and location are required."
logger.warning("Missing required fields.") logger.warning("Missing required fields.")
elif (len(first_name) > FIELD_MAX['first_name'] or
len(last_name) > FIELD_MAX['last_name'] or
len(location) > FIELD_MAX['location']):
error = "A required field exceeds the maximum allowed length."
elif email and len(email) > FIELD_MAX['email']:
error = "Email address is too long."
elif comment and len(comment) > FIELD_MAX['comment']:
error = f"Comment is too long (max {FIELD_MAX['comment']:,} characters)."
elif email and not is_valid_email(email): elif email and not is_valid_email(email):
error = "Invalid email address." error = "Invalid email address."
logger.warning("Invalid email: %s", email) logger.warning("Invalid email: %s", email)
@@ -164,6 +299,13 @@ def index():
error="Unable to save your entry. Please try again.", error="Unable to save your entry. Please try again.",
guests=[]) guests=[])
logger.info("Added guest: %s %s from %s", first_name, last_name, location) logger.info("Added guest: %s %s from %s", first_name, last_name, location)
threading.Thread(target=_fire_webhook, args=({
"first_name": first_name,
"last_name": last_name,
"email": email,
"location": location,
"newsletter_opt_in": newsletter_opt_in,
},), daemon=True).start()
return redirect(url_for('index')) return redirect(url_for('index'))
try: try:
@@ -178,27 +320,63 @@ def index():
logger.info("Rendering index with %d guests.", len(guests)) logger.info("Rendering index with %d guests.", len(guests))
return render_template('index.html', error=error, guests=guests) return render_template('index.html', error=error, guests=guests)
def require_admin_auth(f): # ---------------------------------------------------------------------------
@wraps(f) # Admin auth routes
def decorated(*args, **kwargs): # ---------------------------------------------------------------------------
def _admin_configured():
return bool(os.environ.get('ADMIN_USER') and os.environ.get('ADMIN_PASSWORD'))
@app.route('/admin/login', methods=['GET', 'POST'])
@limiter.limit("10 per minute", methods=["POST"])
def admin_login():
if not _admin_configured():
abort(503)
if current_user.is_authenticated:
return redirect(url_for('admin'))
error = None
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
admin_user = os.environ.get('ADMIN_USER') admin_user = os.environ.get('ADMIN_USER')
admin_password = os.environ.get('ADMIN_PASSWORD') admin_password = os.environ.get('ADMIN_PASSWORD')
if not admin_user or not admin_password: # Check superadmin first
logger.error("ADMIN_USER and ADMIN_PASSWORD must be set to enable the admin interface.") if admin_user and username == admin_user and password == admin_password:
abort(503) login_user(User(f's:{username}', username, 'superadmin'))
auth = request.authorization logger.info("Superadmin '%s' logged in.", username)
if not auth or auth.username != admin_user or auth.password != admin_password: return redirect(request.args.get('next') or url_for('admin'))
return Response( # Check DB users
'Authentication required.', try:
401, conn = sqlite3.connect(DATABASE)
{'WWW-Authenticate': 'Basic realm="Admin"'} c = conn.cursor()
) row = c.execute(
return f(*args, **kwargs) 'SELECT id, password_hash, role FROM users WHERE username = ?', (username,)
return decorated ).fetchone()
conn.close()
if row and check_password_hash(row[1], password):
login_user(User(f'u:{row[0]}', username, row[2]))
logger.info("User '%s' (role=%s) logged in.", username, row[2])
return redirect(request.args.get('next') or url_for('admin'))
except sqlite3.Error as e:
logger.error("Database error during login: %s", e)
error = 'Invalid username or password.'
logger.warning("Failed login attempt for username '%s'.", username)
return render_template('admin_login.html', error=error)
@app.route('/admin/logout')
def admin_logout():
logout_user()
return redirect(url_for('admin_login'))
# ---------------------------------------------------------------------------
# Admin routes
# ---------------------------------------------------------------------------
@app.route('/admin') @app.route('/admin')
@require_admin_auth @login_required
def admin(): def admin():
if not _admin_configured():
abort(503)
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
per_page = 25 per_page = 25
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -217,11 +395,16 @@ def admin():
guests = [] guests = []
total = 0 total = 0
total_pages = (total + per_page - 1) // per_page total_pages = (total + per_page - 1) // per_page
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages, total=total) return render_template('admin.html', guests=guests, page=page, total_pages=total_pages,
total=total)
@app.route('/admin/delete/<int:entry_id>', methods=['POST']) @app.route('/admin/delete/<int:entry_id>', methods=['POST'])
@require_admin_auth @login_required
def admin_delete(entry_id): def admin_delete(entry_id):
if not _admin_configured():
abort(503)
if current_user.role == 'viewer':
abort(403)
try: try:
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
c = conn.cursor() c = conn.cursor()
@@ -233,7 +416,76 @@ def admin_delete(entry_id):
logger.error("Database error deleting guest %d: %s", entry_id, e) logger.error("Database error deleting guest %d: %s", entry_id, e)
return redirect(url_for('admin', page=request.args.get('page', 1))) return redirect(url_for('admin', page=request.args.get('page', 1)))
@app.route('/admin/users')
@login_required
def admin_users():
if not _admin_configured():
abort(503)
if current_user.role != 'superadmin':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
users = c.execute('SELECT id, username, role FROM users ORDER BY username').fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error in admin_users: %s", e)
users = []
return render_template('admin_users.html', users=users)
@app.route('/admin/users/add', methods=['POST'])
@login_required
def admin_users_add():
if not _admin_configured():
abort(503)
if current_user.role != 'superadmin':
abort(403)
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
role = request.form.get('role', '').strip()
if not username or not password or role not in ('admin', 'viewer'):
return redirect(url_for('admin_users'))
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
(username, generate_password_hash(password), role)
)
conn.commit()
conn.close()
logger.info("Superadmin added user '%s' with role '%s'", username, role)
except sqlite3.IntegrityError:
logger.warning("Attempted to add duplicate username '%s'", username)
except sqlite3.Error as e:
logger.error("Database error adding user: %s", e)
return redirect(url_for('admin_users'))
@app.route('/admin/users/delete/<int:user_id>', methods=['POST'])
@login_required
def admin_users_delete(user_id):
if not _admin_configured():
abort(503)
if current_user.role != 'superadmin':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('DELETE FROM users WHERE id = ?', (user_id,))
conn.commit()
conn.close()
logger.info("Superadmin deleted user id=%d", user_id)
except sqlite3.Error as e:
logger.error("Database error deleting user %d: %s", user_id, e)
return redirect(url_for('admin_users'))
# ---------------------------------------------------------------------------
# API
# ---------------------------------------------------------------------------
@app.route('/api/guests', methods=['GET']) @app.route('/api/guests', methods=['GET'])
@limiter.limit("100 per hour")
@csrf.exempt
def api_guests(): def api_guests():
api_key = request.headers.get('X-API-Key') api_key = request.headers.get('X-API-Key')
if api_key != os.environ.get("API_KEY"): if api_key != os.environ.get("API_KEY"):
+7 -1
View File
@@ -1,4 +1,6 @@
#!/bin/sh #!/bin/sh
set -e
# Fix ownership of the data directory so appuser can write the database. # Fix ownership of the data directory so appuser can write the database.
# This runs as root (no USER directive in Dockerfile) and is safe because # This runs as root (no USER directive in Dockerfile) and is safe because
# we immediately drop privileges via gosu before starting the app. # we immediately drop privileges via gosu before starting the app.
@@ -9,4 +11,8 @@ chown -R appuser:appuser "$DATA_DIR"
envsubst < /app/templates/index.html.template > /app/templates/index.html envsubst < /app/templates/index.html.template > /app/templates/index.html
# Drop to appuser and start Gunicorn # Drop to appuser and start Gunicorn
exec gosu appuser gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3} exec gosu appuser gunicorn \
--bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--timeout 30 \
app:app
+4 -1
View File
@@ -11,4 +11,7 @@ GID=1000
SITE_TITLE="The Montana Dinosaur Center Visitor Log" SITE_TITLE="The Montana Dinosaur Center Visitor Log"
LOGO_URL="/static/images/logo.png" LOGO_URL="/static/images/logo.png"
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=changeme ADMIN_PASSWORD=changeme
SECRET_KEY=change-this-to-a-random-secret-key
# Optional: POST new signups as JSON to this URL (e.g. an n8n Webhook node)
WEBHOOK_URL=
+4 -1
View File
@@ -1,5 +1,8 @@
Flask>=3.1.3 Flask>=3.1.3
Flask-WTF>=1.2
Werkzeug>=3.0.6 Werkzeug>=3.0.6
Flask-Limiter>=3.0 Flask-Limiter>=3.0
Flask-Login>=0.6
email-validator>=2.0 email-validator>=2.0
gunicorn gunicorn
tzdata
+11 -2
View File
@@ -10,7 +10,13 @@
<div class="container py-4"> <div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Guestbook Admin</h1> <h1 class="h3 mb-0">Guestbook Admin</h1>
<span class="text-muted">{{ total }} total entries</span> <div class="d-flex align-items-center gap-3">
<span class="text-muted">{{ current_user.username }} &middot; {{ total }} entries</span>
{% if current_user.role == 'superadmin' %}
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary btn-sm">Manage Users</a>
{% endif %}
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
</div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
@@ -36,12 +42,15 @@
<td>{{ g[4] }}</td> <td>{{ g[4] }}</td>
<td>{{ g[5] or '—' }}</td> <td>{{ g[5] or '—' }}</td>
<td>{{ 'Yes' if g[6] else 'No' }}</td> <td>{{ 'Yes' if g[6] else 'No' }}</td>
<td class="text-nowrap">{{ g[7] }}</td> <td class="text-nowrap">{{ g[7] | localtime }}</td>
<td> <td>
{% if current_user.role != 'viewer' %}
<form method="POST" action="{{ url_for('admin_delete', entry_id=g[0]) }}?page={{ page }}" <form method="POST" action="{{ url_for('admin_delete', entry_id=g[0]) }}?page={{ page }}"
onsubmit="return confirm('Delete entry for {{ g[1] }} {{ g[2] }}?')"> onsubmit="return confirm('Delete entry for {{ g[1] }} {{ g[2] }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-danger btn-sm">Delete</button> <button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form> </form>
{% endif %}
</td> </td>
</tr> </tr>
{% else %} {% else %}
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Guestbook Admin — Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body class="bg-light">
<div class="container py-5" style="max-width: 400px;">
<h1 class="h4 mb-4 text-center">Admin Login</h1>
<div class="card">
<div class="card-body">
{% if error %}
<div class="alert alert-danger py-2">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('admin_login', next=request.args.get('next', '')) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" id="username" name="username" class="form-control"
autocomplete="username" required autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" id="password" name="password" class="form-control"
autocomplete="current-password" required />
</div>
<button type="submit" class="btn btn-primary w-100">Log In</button>
</form>
</div>
</div>
</div>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Guestbook Admin — Users</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body class="bg-light">
<div class="container py-4" style="max-width: 700px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">User Management</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('admin') }}" class="btn btn-outline-secondary btn-sm">Back to Entries</a>
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
</div>
</div>
<div class="card mb-4">
<div class="card-header">Add User</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin_users_add') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="row g-2">
<div class="col-sm-4">
<input type="text" name="username" class="form-control" placeholder="Username" required />
</div>
<div class="col-sm-4">
<input type="password" name="password" class="form-control" placeholder="Password" required />
</div>
<div class="col-sm-2">
<select name="role" class="form-select">
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="col-sm-2">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</div>
</form>
</div>
</div>
<table class="table table-bordered bg-white">
<thead class="table-dark">
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u[1] }}</td>
<td><span class="badge bg-{{ 'danger' if u[2] == 'admin' else 'secondary' }}">{{ u[2] }}</span></td>
<td>
<form method="POST" action="{{ url_for('admin_users_delete', user_id=u[0]) }}"
onsubmit="return confirm('Remove user {{ u[1] }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-danger btn-sm">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center text-muted">No users added yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="text-muted small">
These accounts are in addition to the bootstrap superadmin configured in <code>.env</code>.
Admins can view and delete entries. Viewers can only view.
</p>
</div>
</body>
</html>
+17 -5
View File
@@ -8,7 +8,18 @@
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap" rel="stylesheet" />
<style> <style>
body {
font-family: 'Open Sans', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Vollkorn', serif;
font-weight: 700;
}
/* Scrolling marquee styles */ /* Scrolling marquee styles */
.scrolling-wrapper { .scrolling-wrapper {
overflow: hidden; overflow: hidden;
@@ -59,19 +70,20 @@
{% endif %} {% endif %}
<form method="post" action="/" class="mb-4"> <form method="post" action="/" class="mb-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="mb-3"> <div class="mb-3">
<label for="first_name" class="form-label">First Name(s):</label> <label for="first_name" class="form-label">First Name(s):</label>
<input type="text" class="form-control" id="first_name" name="first_name" required /> <input type="text" class="form-control" id="first_name" name="first_name" maxlength="100" required />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="last_name" class="form-label">Last Name:</label> <label for="last_name" class="form-label">Last Name:</label>
<input type="text" class="form-control" id="last_name" name="last_name" required /> <input type="text" class="form-control" id="last_name" name="last_name" maxlength="100" required />
</div> </div>
<!-- Email + Newsletter Block (fully fixed) --> <!-- Email + Newsletter Block (fully fixed) -->
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email (Optional):</label> <label for="email" class="form-label">Email (Optional):</label>
<input type="email" class="form-control" id="email" name="email" /> <input type="email" class="form-control" id="email" name="email" maxlength="254" />
<div class="form-check mt-2"> <div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in" <input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
@@ -84,13 +96,13 @@
<div class="mb-3"> <div class="mb-3">
<label for="location" class="form-label">Location:</label> <label for="location" class="form-label">Location:</label>
<input type="text" class="form-control" id="location" name="location" required /> <input type="text" class="form-control" id="location" name="location" maxlength="100" required />
</div> </div>
<!-- Comment field hidden by default --> <!-- Comment field hidden by default -->
<div class="mb-3" id="comment-field" style="display: none;"> <div class="mb-3" id="comment-field" style="display: none;">
<label for="comment" class="form-label">Comment (Optional):</label> <label for="comment" class="form-label">Comment (Optional):</label>
<textarea class="form-control" id="comment" name="comment" rows="3"></textarea> <textarea class="form-control" id="comment" name="comment" rows="3" maxlength="2000"></textarea>
</div> </div>
<button type="submit" class="btn btn-primary">Submit</button> <button type="submit" class="btn btn-primary">Submit</button>