41 Commits

Author SHA1 Message Date
steve 4f0a7df22a feat: add role-based access control with database-backed users 2026-03-10 10:29:42 -06:00
steve b2e7eeb570 feat: add hardened HTTP Basic Auth for admin interface 2026-03-10 10:07:09 -06:00
steve 047f1a8c8b feat: add paginated admin interface for viewing and deleting entries 2026-03-10 09:57:28 -06:00
steve c2b6c1b460 fix: add Bearer token authentication to ntfy notification 2026-03-09 23:47:01 -06:00
steve e733e7b092 fix: enable verbose curl output for ntfy debugging 2026-03-09 23:41:46 -06:00
steve 9fe3bc43d0 chore: add TODO for admin interface 2026-03-09 23:37:13 -06:00
steve a0e6042300 feat: add ntfy push notification on successful Docker Hub push 2026-03-09 23:16:54 -06:00
steve 05bcf10614 fix: resolve volume permission error for non-root container user
Entrypoint now runs as root, chowns the data directory to appuser,
then drops privileges via gosu before starting Gunicorn. This prevents
sqlite3.OperationalError on mounted volumes owned by root.
2026-03-09 23:07:49 -06:00
steve 78ef3eeb85 refactor: replace init_db with lightweight schema migration system
- Add MIGRATIONS list — each entry is a list of SQL statements for
  that schema version; append new lists to add future migrations,
  never modify existing ones
- Add schema_version table to track applied migrations
- migrate_db() runs on startup and applies any pending versions
  automatically; safe to run against existing DBs (v1 uses
  CREATE IF NOT EXISTS so it no-ops on the existing table/indexes)
2026-03-09 21:01:35 -06:00
steve 46dca45e04 fix: correct WORKERS var, export path, and seamless marquee loop
- entrypoint.sh: use GUNICORN_WORKERS to match example.env (#17)
- guestbook_export.py: read DATABASE_PATH from env instead of
  hardcoded relative path (#18)
- Scrolling marquee: duplicate guest list for seamless loop,
  animate translateX(0) to translateX(-50%), increase font to
  1.25rem, fix JS speed calc to use half content width (#20)
2026-03-09 20:52:00 -06:00
steve 2dc276f098 fix: improve profanity filter to catch spacing and embedding bypasses
Add a secondary normalized substring check: strips all non-alpha chars
then checks if any banned word appears as a substring. This catches:
- Spacing tricks: 'f u c k'
- Embedded forms: 'fucking'
Note: substring matching can produce false positives (e.g. 'classic'
contains 'ass'). Trade-off accepted for a museum kiosk context.
2026-03-09 20:48:26 -06:00
steve e6d742f92e fix: replace regex email validation with email-validator
Swap hand-rolled regex for the email-validator library which handles
RFC 5322 edge cases correctly. check_deliverability=False skips DNS
lookups (not viable on an intranet). Blank email still passes — only
a non-empty, malformed address triggers the error.
2026-03-09 20:36:54 -06:00
steve e0d72f8057 feat: add rate limiting to form submission
Add Flask-Limiter and cap POST submissions to 5 per minute per IP.
GET requests are not limited. Uses in-memory storage (appropriate
for single-instance kiosk deployment).
2026-03-09 20:29:17 -06:00
steve d98dd1518b Remove CSRF TODO — closed as won't fix in #11 2026-03-09 20:26:42 -06:00
steve 920463b4a7 fix: add database error handling throughout app
Wrap all sqlite3 operations in try/except sqlite3.Error:
- SELECT on validation error path: falls back to empty guest list
- INSERT on form submit: shows user-friendly retry message
- SELECT on page load: falls back to empty guest list
- SELECT in /api/guests: returns 503 JSON response
2026-03-09 20:24:09 -06:00
steve a178e6193b Keep PII logging as intentional — close #8
Logging guest name and location is appropriate here: visitors knowingly
submit this info for a newsletter, and the log is useful for confirming
submissions and debugging on an intranet-only kiosk.
2026-03-09 20:19:28 -06:00
steve 0c4d3ab15d perf: add DB indexes and cap guest queries at 100 rows
- Add idx_guests_id and idx_guests_email indexes in init_db()
- Cap all SELECT queries on the guests table to LIMIT 100 to prevent
  unbounded memory growth as the guestbook accumulates entries
2026-03-09 20:17:34 -06:00
steve 3e17574fe6 fix: upgrade to Flask 3.x and replace before_first_request
- Pin Flask to >=3.1.3 to resolve all outstanding Dependabot CVEs
  (session cookie Vary header, Werkzeug DoS/RCE/safe_join vulns)
- Replace removed @before_first_request decorator with app.app_context()
  call at module level, compatible with Flask 3.0+
2026-03-09 20:15:14 -06:00
steve 0c8491ce7a feat: run container as non-root user
Create appuser with configurable UID/GID (default 1000, matching
example.env PID/GID vars) and switch to it before starting Gunicorn.
Override at build time with --build-arg UID=... --build-arg GID=...

Note: the /data volume mount must be owned by the matching UID on the
host for the DB to remain writable.
2026-03-09 20:13:21 -06:00
steve 1a0a1371bc fix: correct marquee scroll speed and add code TODOs
- Fixed scrolling marquee to use a fixed px/s speed via JS instead of
  a fixed duration, preventing it from speeding up as entries are added
- Added inline TODO comments throughout codebase to track known issues
  (rate limiting, CSRF, unbounded queries, deprecated Flask decorator,
  PII logging, schema versioning, Docker non-root user, etc.)
- Added todo-to-issue GitHub Action to auto-create Issues from TODOs on push to main
- Added .claude/ to .gitignore
2026-03-09 19:30:13 -06:00
steve d260bc6f9f docs: remove outdated project structure section from README 2025-04-04 18:48:20 -06:00
steve 412d373421 docs: add Portainer setup instructions to README 2025-04-04 16:28:34 -06:00
steve bae3ddda32 - make the newsletter checkbox generic
- Added LOGO_URL to `example.env` and index.html template
- Rewrote README.md to reflect current methods of installing and configuring
2025-04-04 15:36:12 -06:00
steve 85a0096846 fix: move index.html template to correct Flask templates directory 2025-04-04 15:04:46 -06:00
steve d76a95e57b chore: move entrypoint.sh to project root 2025-04-04 14:59:05 -06:00
steve 91d4715e19 fix: correct DATABASE_PATH in example.env 2025-04-04 14:55:38 -06:00
steve ffa09e3daa Making the header/title are a variable
Refactor Dockerfile and entrypoint script; add index.html.template and update example.env
2025-04-04 14:46:29 -06:00
steve dfb350f8a8 chore: remove dev Dockerfile and add example docker-compose 2025-04-04 14:28:26 -06:00
steve ff175edcf6 chore: remove committed docker-compose.yml
Cleaning up files so it's easier to deploy. Look for example.docker-compose.yml instead
2025-04-04 14:20:04 -06:00
Steve Dogiakos 2bbe30e1e0 ci: add Docker Hub push step to workflow 2025-04-04 14:07:11 -06:00
steve af3ad37b4c chore: remove committed .env file
Just making the example.env available to the public.
2025-04-04 13:51:14 -06:00
Steve Dogiakos bc9fe0909e Created example.env so I don't have to keep messing
with my setup.
2025-04-04 13:50:13 -06:00
steve c04ffaf16d chore: add MIT license
Adding the MIT license to the project
2025-04-02 19:47:44 -06:00
steve 5091518bd2 ci: update Docker image workflow
changed secrete to vars for DOCKER_USERNAME
2025-04-02 19:36:36 -06:00
steve caf6f9e970 docs: update README
Added section about API use
2025-04-02 19:28:21 -06:00
steve 20dd611b70 ci: add Docker build GitHub Actions workflow
Added Docker secrets so hopefully it will run now.
2025-04-02 18:16:53 -06:00
Steve Dogiakos ada25eba70 fix: remove duplicate email field introduced in previous commit 2025-04-02 15:48:56 -06:00
Steve Dogiakos f34c163a76 Add API to app.py so I can use n8n to export the entries.
Added opt-out newsletter checkbox and the appropriate places to insert it to the db.
2025-04-02 15:31:33 -06:00
Steve Dogiakos cfcd301eb0 ci: remove Docker Hub push workflow 2025-04-02 09:44:59 -06:00
Steve Dogiakos 86529e0728 Rename production.Dockerfile to development.Dockerfile.
It was a late night, ok? Updated README.md to include removal of DB from repo.
2025-04-02 09:29:25 -06:00
steve dba4c21a5e ci: add Docker image build workflow 2025-04-02 08:38:24 -06:00
16 changed files with 750 additions and 158 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Docker Image CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to DockerHub
if: github.event_name == 'push'
uses: docker/login-action@v2
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build the Docker image
id: build-image
run: |
IMAGE_TAG=my-image-name:${{ github.sha }}
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: Push the Docker image
if: github.event_name == 'push'
run: |
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
docker push snachodog/kiosk-guestbook:latest
- name: Notify via ntfy
if: github.event_name == 'push'
env:
NTFY_URL: ${{ secrets.NTFY_URL }}
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
run: |
curl -s -o /dev/null \
-H "Title: kiosk-guestbook image pushed to Docker Hub" \
-H "Tags: white_check_mark" \
-H "Authorization: Bearer $NTFY_TOKEN" \
-d "The kiosk-guestbook container has been pushed to Docker Hub and is ready to pull. Commit: ${{ github.sha }} — ${{ github.event.head_commit.message }}" \
"$NTFY_URL"
+14
View File
@@ -0,0 +1,14 @@
name: TODO to Issue
on:
push:
branches: [ "main" ]
jobs:
todo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: alstr/todo-to-issue-action@v5
with:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5
View File
@@ -184,3 +184,8 @@ cython_debug/
# VS Code
.vscode/
# Claude Code
.claude/
.env
docker-compose.yml
+19 -4
View File
@@ -4,18 +4,33 @@ FROM python:3.9-slim
# Set the working directory
WORKDIR /app
# Install dependencies
# Install system dependencies (including gettext for envsubst and gosu for privilege dropping)
RUN apt-get update && apt-get install -y gettext gosu && rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
# Copy the application code and template files
COPY . .
# Copy the entrypoint script into the container and make it executable
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Set environment variables (can be overridden by .env)
ENV FLASK_ENV=production
# Expose the port (Gunicorn will run on 8000)
EXPOSE 8000
# Run the app with Gunicorn; use 3 workers (can be tuned via .env)
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app", "--workers", "3"]
# Create a non-root user. UID/GID match the PID/GID vars in example.env (default 1000).
# Override at build time with: docker build --build-arg UID=1001 --build-arg GID=1001
ARG UID=1000
ARG GID=1000
RUN groupadd -g ${GID} appuser && useradd -u ${UID} -g ${GID} -s /bin/sh -M appuser
RUN chown -R appuser:appuser /app /entrypoint.sh
# Entrypoint runs as root, fixes volume permissions, then drops to appuser via gosu
# Use the entrypoint script as the container's command
CMD ["/entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Montana Dinosaur Center
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+99 -48
View File
@@ -9,37 +9,16 @@ A simple Flask-based guestbook application designed for an internal museum kiosk
- Dynamic Form Behavior:
The comment field is hidden by default and only revealed when the first name, last name, and location fields each contain at least 3 characters.
- Input Validation:
Ensures required fields (first name, last name, and location) are filled.
- Validates email format (if provided).
Ensures required fields (first name, last name, and location) are filled and validates email format (if provided).
Uses a profanity filter loaded from en.txt to prevent inappropriate language in comments.
- Logging:
Logs key events and validation errors to help with debugging and monitoring.
Logs key events and validation errors for debugging and monitoring.
- SQLite Database:
Stores guest entries locally, with persistence ensured by mounting a Docker volume.
- Containerized Deployment:
Uses Docker and Docker Compose to create a production-ready environment with Gunicorn as the WSGI server.
## Project Structure
``` bash
kiosk-guestbook/
├── scripts/
│ ├── guestbook_export.py # Script to export guest entries (e.g., for Mailchimp)
│ └── guestbook.db # SQLite database file (if stored here, mainly for development)
├── static/
│ └── images/
│ └── logo.png # Logo for display in the application
├── templates/
│ └── index.html # Main HTML template for the guestbook
├── .env # Environment variables for Docker Compose (production settings)
├── app.py # Main Flask application code
├── docker-compose.yml # Docker Compose configuration for container orchestration
├── Dockerfile # Default Dockerfile (development or general usage)
├── en.txt # Profanity list file (one banned word per line)
├── production.Dockerfile # Optional Dockerfile optimized for production
├── README.md # Project documentation
└── requirements.txt # Python dependencies (Flask, Gunicorn, etc.)
```
Uses Docker and Docker Compose for a production-ready environment with Gunicorn as the WSGI server.
- Configurable Template:
The applications title and logo can be dynamically configured via environment variables without rebuilding the image.
## Getting Started
@@ -47,40 +26,112 @@ kiosk-guestbook/
- Docker
- Docker Compose
- Optionally, Portainer for GUI-based container management
### Building and Running the Application
## Running the Application
### Build and Start Containers
Before proceeding, youll need to have the example configuration files. These files—example.docker-compose.yml and example.env—are included in the repository. You can download them by cloning the entire repository:
1. From the project root, run:
`docker-compose up --build -d`
This command will build the Docker image, start the container in detached mode, and mount the persistent volume at `/data` for the SQLite database.
```bash
git clone https://github.com/tmdinosaurcenter/kiosk-guestbook.git
cd kiosk-guestbook
```
2. Access the Application:
Open a web browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your .env file).
If you dont wish to clone the entire repo, you can also download the two files individually from GitHub. Once you have them, follow the steps below.
### Deployment with Docker Compose
### Method 1: Using Docker on the CLI
The `docker-compose.yml` is configured to:
1. Copy Example Files:
From the project root, copy the example files:
- Build the image from the Dockerfile.
- Expose the service on the specified port.
- Mount a volume (named `guestbook_data`) at `/data` to persist your database.
- Load environment variables from the `.env` file
``` bash
cp example.docker-compose.yml docker-compose.yml
cp example.env .env
```
### Logging and Monitoring
2. **Edit the `.env` File (Optional)**
Modify `.env` to customize settings such as `SITE_TITLE`, `LOGO_URL`, `PORT`, etc.
3. **Start the Application**
Run the following command to pull (or use) the pre-built image and start the container:
`docker-compose up -d`
This command starts the container in detached mode, mounts the persistent volume for the SQLite database, and uses your environment variable settings.
4. **Access the Application**
Open your browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your `.env` file).
### Method 2: Running in Portainer
1. **Copy Example Files**
```bash
cp example.docker-compose.yml docker-compose.yml
cp example.env stack.env
```
*Note*: Portainer expects the environment file to be named stack.env rather than .env
2. **Edit `docker-compose.yml`**
In the `docker-compose.yml` file, update the environment file reference from `.env` to `stack.env`
3. **Deploy via Portainer**
- Log in to Portainer and navigate to the "Stacks" section.
- Create a new stack and upload or paste your modified docker-compose.yml along with the `stack.env` file.
- Deploy the stack. Portainer will use stack.env for the environment variables.
4. **Access the Application:**
Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (or your specified port) to view the application.
## Logging and Monitoring
- The application uses Python's built-in logging module.
- Key events (like database initialization, form submissions, and validation errors) are logged.
- Logs can be viewed by running:
- Key events, such as database initialization, form submissions, and validation errors, are logged.
- View logs with:
`docker-compose logs -f`
## 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.
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.
## API Access
Access the API endpoint to export guest entries by navigating to:
`http://your-server-ip:8000/api/guests`
Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-Key` header. This endpoint can be integrated with on-prem automation tools like n8n.
## 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:
```env
ADMIN_USER=admin
ADMIN_PASSWORD=changeme
```
Replace the placeholder values with your own credentials before deploying.
## Additional Notes
- Intranet-Only Deployment:
This application is designed for internal use only. It is not exposed to the public internet.
- Database Persistence:
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
- Production Considerations:
- **Intranet-Only Deployment**
This application is designed for internal use only and is not exposed to the public internet.
The app runs with Gunicorn as a production-ready WSGI server. Make sure to adjust worker counts and resource limits as needed based on your servers specifications.
- **Database Persistence**
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
- **Production Considerations**
The app runs with Gunicorn as a production-ready WSGI server. Adjust worker counts and resource limits as needed based on your servers specifications.
## License
This project is licensed under the [MIT License](LICENSE).
+289 -47
View File
@@ -1,24 +1,23 @@
from flask import Flask, render_template, request, redirect, url_for
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort, Response, g
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from email_validator import validate_email, EmailNotValidError
from werkzeug.security import generate_password_hash, check_password_hash
from functools import wraps
import sqlite3
import re
import logging
import os
import re
# Set up basic logging
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# Use an environment variable for the database path (defaulting to 'guestbook.db')
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
limiter = Limiter(get_remote_address, app=app, default_limits=[])
def load_banned_words():
"""Load a set of banned words from a local file.
Expects 'en.txt' to be in the same directory as this script.
If the file is missing, a minimal fallback set is used.
"""
banned_words = set()
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
if os.path.exists(file_path):
@@ -33,102 +32,345 @@ def load_banned_words():
logger.error("Error reading banned words file: %s", e)
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
else:
logger.warning("Banned words file not found. Using fallback minimal list.")
logger.warning("Banned words file not found. Using fallback list.")
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
return banned_words
# Load the banned words using the helper function.
BANNED_WORDS = load_banned_words()
def contains_banned_words(text):
"""Check if the provided text contains any banned words."""
words = text.lower().split()
for word in words:
word_clean = word.strip(".,!?;:\"'")
if word_clean in BANNED_WORDS:
lower = text.lower()
# Whole-word check (punctuation-stripped) — catches exact matches
for word in lower.split():
if word.strip(".,!?;:\"'") in BANNED_WORDS:
return True
# Normalized substring check — catches spacing tricks (f u c k) and
# embedded forms (fucking). Note: may produce false positives on words
# that contain a banned word as a substring (e.g. "classic" → "ass").
normalized = re.sub(r'[^a-z]', '', lower)
for banned in BANNED_WORDS:
if banned in normalized:
return True
return False
def init_db():
"""Initialize the SQLite database and create the guests table if it doesn't exist."""
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS guests (
# 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 = [
# v1 — initial schema
[
'''CREATE TABLE IF NOT EXISTS guests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
location TEXT NOT NULL,
comment TEXT,
newsletter_opt_in BOOLEAN DEFAULT 1,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
)''',
'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():
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
# Bootstrap the version table and seed it at 0 if empty
c.execute('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)')
if c.execute('SELECT COUNT(*) FROM schema_version').fetchone()[0] == 0:
c.execute('INSERT INTO schema_version VALUES (0)')
conn.commit()
current = c.execute('SELECT version FROM schema_version').fetchone()[0]
pending = MIGRATIONS[current:]
if not pending:
logger.info("Database schema is up to date at v%d.", current)
conn.close()
return
for statements in pending:
current += 1
logger.info("Applying migration v%d...", current)
for sql in statements:
c.execute(sql)
c.execute('UPDATE schema_version SET version = ?', (current,))
conn.commit()
logger.info("Database migrated to v%d.", current)
conn.close()
logger.info("Database initialized.")
def is_valid_email(email):
"""Simple regex-based email validation."""
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return re.match(pattern, email)
try:
validate_email(email, check_deliverability=False)
return True
except EmailNotValidError:
return False
@app.before_first_request
def initialize_database():
"""Ensure the database is initialized before handling the first request."""
init_db()
with app.app_context():
migrate_db()
@app.route('/', methods=['GET', 'POST'])
@limiter.limit("5 per minute", methods=["POST"])
def index():
error = None
if request.method == 'POST':
logger.info("Received POST request with form data.")
logger.info("Received POST request.")
first_name = request.form.get('first_name', '').strip()
last_name = request.form.get('last_name', '').strip()
email = request.form.get('email', '').strip()
location = request.form.get('location', '').strip()
comment = request.form.get('comment', '').strip()
newsletter_opt_in = request.form.get('newsletter_opt_in') == 'on'
if not (first_name and last_name and location):
error = "First name, last name, and location are required."
logger.warning("Validation error: Missing required fields.")
logger.warning("Missing required fields.")
elif email and not is_valid_email(email):
error = "Invalid email address."
logger.warning("Validation error: Invalid email address '%s'.", email)
logger.warning("Invalid email: %s", email)
elif comment and contains_banned_words(comment):
error = "Your comment contains inappropriate language. Please revise."
logger.warning("Validation error: Inappropriate language detected in comment.")
logger.warning("Profanity detected in comment.")
if error:
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC LIMIT 100')
guests = c.fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error loading guests: %s", e)
guests = []
return render_template('index.html', error=error, guests=guests)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute(
'INSERT INTO guests (first_name, last_name, email, location, comment) VALUES (?, ?, ?, ?, ?)',
(first_name, last_name, email, location, comment)
'''
INSERT INTO guests (first_name, last_name, email, location, comment, newsletter_opt_in)
VALUES (?, ?, ?, ?, ?, ?)
''',
(first_name, last_name, email, location, comment, newsletter_opt_in)
)
conn.commit()
conn.close()
logger.info("New guest entry added: %s from %s.", first_name, location)
except sqlite3.Error as e:
logger.error("Database error saving guest: %s", e)
return render_template('index.html',
error="Unable to save your entry. Please try again.",
guests=[])
logger.info("Added guest: %s %s from %s", first_name, last_name, location)
return redirect(url_for('index'))
# For GET requests, retrieve guest entries to display.
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC LIMIT 100')
guests = c.fetchall()
conn.close()
logger.info("Rendering guestbook page with %d entries.", len(guests))
except sqlite3.Error as e:
logger.error("Database error loading guests: %s", e)
guests = []
logger.info("Rendering index with %d guests.", len(guests))
return render_template('index.html', error=error, guests=guests)
def _authenticate():
"""Returns (username, role) for the current request, or None if unauthenticated.
Role is 'superadmin', 'admin', or 'viewer'."""
auth = request.authorization
if not auth:
return None
admin_user = os.environ.get('ADMIN_USER')
admin_password = os.environ.get('ADMIN_PASSWORD')
if admin_user and auth.username == admin_user and auth.password == admin_password:
return (auth.username, 'superadmin')
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
row = c.execute(
'SELECT password_hash, role FROM users WHERE username = ?', (auth.username,)
).fetchone()
conn.close()
if row and check_password_hash(row[0], auth.password):
return (auth.username, row[1])
except sqlite3.Error as e:
logger.error("Database error during authentication: %s", e)
return None
def _unauthorized():
return Response('Authentication required.', 401, {'WWW-Authenticate': 'Basic realm="Admin"'})
def require_any_auth(f):
"""Allows superadmin, admin, and viewer roles."""
@wraps(f)
def decorated(*args, **kwargs):
if not os.environ.get('ADMIN_USER') or not os.environ.get('ADMIN_PASSWORD'):
logger.error("ADMIN_USER and ADMIN_PASSWORD must be set to enable the admin interface.")
abort(503)
user = _authenticate()
if user is None:
return _unauthorized()
g.current_user, g.current_role = user
return f(*args, **kwargs)
return decorated
def require_superadmin(f):
"""Allows only the bootstrap superadmin."""
@wraps(f)
def decorated(*args, **kwargs):
admin_user = os.environ.get('ADMIN_USER')
admin_password = os.environ.get('ADMIN_PASSWORD')
if not admin_user or not admin_password:
abort(503)
auth = request.authorization
if not auth or auth.username != admin_user or auth.password != admin_password:
return _unauthorized()
g.current_user = auth.username
g.current_role = 'superadmin'
return f(*args, **kwargs)
return decorated
@app.route('/admin')
@require_any_auth
def admin():
page = request.args.get('page', 1, type=int)
per_page = 25
offset = (page - 1) * per_page
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
total = c.execute('SELECT COUNT(*) FROM guests').fetchone()[0]
c.execute('''
SELECT id, first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
FROM guests ORDER BY id DESC LIMIT ? OFFSET ?
''', (per_page, offset))
guests = c.fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error in admin: %s", e)
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, current_role=g.current_role)
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
@require_any_auth
def admin_delete(entry_id):
if g.current_role == 'viewer':
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('DELETE FROM guests WHERE id = ?', (entry_id,))
conn.commit()
conn.close()
logger.info("Admin deleted guest entry id=%d", entry_id)
except sqlite3.Error as e:
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')
@require_superadmin
def admin_users():
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'])
@require_superadmin
def admin_users_add():
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'])
@require_superadmin
def admin_users_delete(user_id):
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'))
@app.route('/api/guests', methods=['GET'])
def api_guests():
api_key = request.headers.get('X-API-Key')
if api_key != os.environ.get("API_KEY"):
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('''
SELECT first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
FROM guests
WHERE email IS NOT NULL AND email != ''
ORDER BY id DESC
''')
rows = c.fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error in api_guests: %s", e)
return jsonify({"error": "Database unavailable"}), 503
guests = [
{
"first_name": row[0],
"last_name": row[1],
"email": row[2],
"location": row[3],
"comment": row[4],
"newsletter_opt_in": bool(row[5]),
"timestamp": row[6]
}
for row in rows
]
return jsonify(guests)
if __name__ == '__main__':
# For development use; production (gunicorn) will not execute this block.
init_db()
logger.info("Starting Flask app on host 0.0.0.0, port 8000.")
migrate_db()
logger.info("Starting development server at http://0.0.0.0:8000")
app.run(host='0.0.0.0', port=8000)
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# 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.
DATA_DIR=$(dirname "${DATABASE_PATH:-/data/guestbook.db}")
chown -R appuser:appuser "$DATA_DIR"
# Process index.html.template to create index.html
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}
@@ -1,15 +1,15 @@
version: "3.8"
services:
guestbook:
build: .
image: snachodog/kiosk-guestbook:latest
container_name: guestbook
ports:
- "${PORT:-8000}:8000"
env_file:
- .env
volumes:
# Mount a named volume at /data so that the database file (configured in .env) persists
- /home/steve/kiosk-guestbook:/data
# Mount your local directory to persist data; adjust if you prefer a named volume
- /path/to/guestbook_data:/data
volumes:
guestbook_data:
+5 -2
View File
@@ -3,9 +3,12 @@ PORT=8000
# Flask environment setting (production)
FLASK_ENV=production
# Path to the SQLite database (this file will be stored in the mounted /data volume)
DATABASE_PATH=/data/scripts/guestbook.db
DATABASE_PATH=/data/guestbook.db
# Number of Gunicorn workers (adjust as needed)
GUNICORN_WORKERS=3
PID=1000
GID=1000
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
LOGO_URL="/static/images/logo.png"
ADMIN_USER=admin
ADMIN_PASSWORD=changeme
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.9-slim
WORKDIR /app
# Copy and install Python dependencies.
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the app code.
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
+3 -1
View File
@@ -1,3 +1,5 @@
Flask==2.2.5
Flask>=3.1.3
Werkzeug>=3.0.6
Flask-Limiter>=3.0
email-validator>=2.0
gunicorn
+2 -2
View File
@@ -1,8 +1,8 @@
import csv
import os
import sqlite3
# Update the database file path if needed.
DATABASE = 'guestbook.db'
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
EXPORT_FILE = 'mailchimp_export.csv'
def export_guestbook_to_csv():
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Guestbook Admin</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">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Guestbook Admin</h1>
<div class="d-flex align-items-center gap-3">
<span class="text-muted">{{ total }} total entries</span>
{% if current_role == 'superadmin' %}
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary btn-sm">Manage Users</a>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered table-hover bg-white">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Location</th>
<th>Comment</th>
<th>Newsletter</th>
<th>Timestamp</th>
<th></th>
</tr>
</thead>
<tbody>
{% for g in guests %}
<tr>
<td class="text-muted">{{ g[0] }}</td>
<td>{{ g[1] }} {{ g[2] }}</td>
<td>{{ g[3] or '—' }}</td>
<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>
{% if current_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] }}?')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center text-muted">No entries found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<nav>
<ul class="pagination">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin', page=page-1) }}">Previous</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin', page=page+1) }}">Next</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
<!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>
<a href="{{ url_for('admin') }}" class="btn btn-outline-secondary btn-sm">Back to Entries</a>
</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') }}">
<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] }}?')">
<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>
@@ -2,11 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Montana Dinosaur Center Visitor Log</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${SITE_TITLE}</title>
<!-- 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" />
<style>
/* Scrolling marquee styles */
.scrolling-wrapper {
@@ -22,16 +23,17 @@
.scrolling-content {
display: inline-block;
padding: 10px;
animation: scroll-left 20s linear infinite;
font-size: 1.25rem;
animation: scroll-left linear infinite;
}
@keyframes scroll-left {
0% {
transform: translateX(100%);
transform: translateX(0);
}
100% {
transform: translateX(-100%);
transform: translateX(-50%);
}
}
</style>
@@ -40,8 +42,8 @@
<body>
<div class="container mt-5 mb-5">
<header class="d-flex align-items-center mb-4">
<img src="static/images/logo.png" alt="Museum Logo" class="me-3" style="height: 50px;">
<h1 class="h3 mb-0">The Montana Dinosaur Center Visitor Log</h1>
<img src="${LOGO_URL}" alt="Logo" class="me-3" style="height: 50px;" />
<h1 class="h3 mb-0">${SITE_TITLE}</h1>
</header>
<!-- Brief instructions for the form -->
@@ -59,61 +61,99 @@
<form method="post" action="/" class="mb-4">
<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" 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" 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" />
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
checked />
<label class="form-check-label" for="newsletter_opt_in">
Subscribe our newsletter
</label>
</div>
</div>
<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" 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>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
<!-- Scrolling Guest Entries at the Bottom -->
<!-- Content is duplicated so the loop is seamless: animate 0 → -50% -->
<div class="scrolling-wrapper">
<div class="scrolling-content">
{% for guest in guests %}
<span class="me-4">
<span class="me-5">
<strong>{{ guest[0] }}</strong> from {{ guest[1] }}
</span>
{% endfor %}
{% for guest in guests %}
<span class="me-5">
<strong>{{ guest[0] }}</strong> from {{ guest[1] }}
</span>
{% endfor %}
</div>
</div>
<!-- Set scrolling speed to a fixed pixels-per-second rate -->
<script>
(function () {
const pixelsPerSecond = 80;
const content = document.querySelector(".scrolling-content");
function updateScrollSpeed() {
// Travel distance is half the total width (one copy of the list)
const oneCopyWidth = content.offsetWidth / 2;
content.style.animationDuration = (oneCopyWidth / pixelsPerSecond) + "s";
}
updateScrollSpeed();
window.addEventListener("resize", updateScrollSpeed);
})();
</script>
<!-- JavaScript to reveal the comment field -->
<script>
document.addEventListener("DOMContentLoaded", function () {
const firstNameInput = document.getElementById('first_name');
const lastNameInput = document.getElementById('last_name');
const locationInput = document.getElementById('location');
const commentField = document.getElementById('comment-field');
const firstNameInput = document.getElementById("first_name");
const lastNameInput = document.getElementById("last_name");
const locationInput = document.getElementById("location");
const commentField = document.getElementById("comment-field");
function checkFields() {
if (firstNameInput.value.trim().length >= 3 &&
if (
firstNameInput.value.trim().length >= 3 &&
lastNameInput.value.trim().length >= 3 &&
locationInput.value.trim().length >= 3) {
commentField.style.display = 'block';
locationInput.value.trim().length >= 3
) {
commentField.style.display = "block";
} else {
commentField.style.display = 'none';
commentField.style.display = "none";
}
}
firstNameInput.addEventListener('input', checkFields);
lastNameInput.addEventListener('input', checkFields);
locationInput.addEventListener('input', checkFields);
firstNameInput.addEventListener("input", checkFields);
lastNameInput.addEventListener("input", checkFields);
locationInput.addEventListener("input", checkFields);
});
</script>