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
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
if: github.event_name == 'push'
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -25,6 +25,13 @@ jobs:
docker build . --file Dockerfile --tag $IMAGE_TAG
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# 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
if: github.event_name == 'push'
run: |
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
todo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: alstr/todo-to-issue-action@v5
with:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,5 +1,5 @@
# Use a lightweight Python image
FROM python:3.9-slim
FROM python:3.14-slim
# Set the working directory
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
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
@@ -112,14 +132,28 @@ Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-
## 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
ADMIN_USER=admin
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
+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 os
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
logging.basicConfig(level=logging.INFO)
@@ -14,7 +22,85 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
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=[])
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():
banned_words = set()
@@ -37,6 +123,14 @@ def 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):
lower = text.lower()
# Whole-word check (punctuation-stripped) — catches exact matches
@@ -52,6 +146,10 @@ def contains_banned_words(text):
return True
return False
# ---------------------------------------------------------------------------
# Database migrations
# ---------------------------------------------------------------------------
# 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.
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_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():
@@ -111,6 +218,26 @@ def is_valid_email(email):
with app.app_context():
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'])
@limiter.limit("5 per minute", methods=["POST"])
def index():
@@ -127,6 +254,14 @@ def index():
if not (first_name and last_name and location):
error = "First name, last name, and location are required."
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):
error = "Invalid email address."
logger.warning("Invalid email: %s", email)
@@ -164,6 +299,13 @@ def index():
error="Unable to save your entry. Please try again.",
guests=[])
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'))
try:
@@ -178,27 +320,63 @@ def index():
logger.info("Rendering index with %d guests.", len(guests))
return render_template('index.html', error=error, guests=guests)
def require_admin_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
# ---------------------------------------------------------------------------
# Admin auth routes
# ---------------------------------------------------------------------------
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_password = os.environ.get('ADMIN_PASSWORD')
if not admin_user or not admin_password:
logger.error("ADMIN_USER and ADMIN_PASSWORD must be set to enable the admin interface.")
abort(503)
auth = request.authorization
if not auth or auth.username != admin_user or auth.password != admin_password:
return Response(
'Authentication required.',
401,
{'WWW-Authenticate': 'Basic realm="Admin"'}
)
return f(*args, **kwargs)
return decorated
# Check superadmin first
if admin_user and username == admin_user and password == admin_password:
login_user(User(f's:{username}', username, 'superadmin'))
logger.info("Superadmin '%s' logged in.", username)
return redirect(request.args.get('next') or url_for('admin'))
# Check DB users
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
row = c.execute(
'SELECT id, password_hash, role FROM users WHERE username = ?', (username,)
).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')
@require_admin_auth
@login_required
def admin():
if not _admin_configured():
abort(503)
page = request.args.get('page', 1, type=int)
per_page = 25
offset = (page - 1) * per_page
@@ -217,11 +395,16 @@ def admin():
guests = []
total = 0
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'])
@require_admin_auth
@login_required
def admin_delete(entry_id):
if not _admin_configured():
abort(503)
if current_user.role == 'viewer':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
@@ -233,7 +416,76 @@ def admin_delete(entry_id):
logger.error("Database error deleting guest %d: %s", entry_id, e)
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'])
@limiter.limit("100 per hour")
@csrf.exempt
def api_guests():
api_key = request.headers.get('X-API-Key')
if api_key != os.environ.get("API_KEY"):
+7 -1
View File
@@ -1,4 +1,6 @@
#!/bin/sh
set -e
# 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
# 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
# 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
+3
View File
@@ -12,3 +12,6 @@ SITE_TITLE="The Montana Dinosaur Center Visitor Log"
LOGO_URL="/static/images/logo.png"
ADMIN_USER=admin
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=
+3
View File
@@ -1,5 +1,8 @@
Flask>=3.1.3
Flask-WTF>=1.2
Werkzeug>=3.0.6
Flask-Limiter>=3.0
Flask-Login>=0.6
email-validator>=2.0
gunicorn
tzdata
+11 -2
View File
@@ -10,7 +10,13 @@
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<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 class="table-responsive">
@@ -36,12 +42,15 @@
<td>{{ g[4] }}</td>
<td>{{ g[5] or '—' }}</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>
{% if current_user.role != 'viewer' %}
<form method="POST" action="{{ url_for('admin_delete', entry_id=g[0]) }}?page={{ page }}"
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>
</form>
{% endif %}
</td>
</tr>
{% 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 -->
<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>
body {
font-family: 'Open Sans', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Vollkorn', serif;
font-weight: 700;
}
/* Scrolling marquee styles */
.scrolling-wrapper {
overflow: hidden;
@@ -59,19 +70,20 @@
{% endif %}
<form method="post" action="/" class="mb-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="mb-3">
<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 class="mb-3">
<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>
<!-- Email + Newsletter Block (fully fixed) -->
<div class="mb-3">
<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">
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
@@ -84,13 +96,13 @@
<div class="mb-3">
<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>
<!-- Comment field hidden by default -->
<div class="mb-3" id="comment-field" style="display: none;">
<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>
<button type="submit" class="btn btn-primary">Submit</button>