14 Commits

Author SHA1 Message Date
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
9 changed files with 203 additions and 96 deletions
+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 }}
+3
View File
@@ -184,5 +184,8 @@ cython_debug/
# VS Code # VS Code
.vscode/ .vscode/
# Claude Code
.claude/
.env .env
docker-compose.yml docker-compose.yml
+8
View File
@@ -24,5 +24,13 @@ ENV FLASK_ENV=production
# Expose the port (Gunicorn will run on 8000) # Expose the port (Gunicorn will run on 8000)
EXPOSE 8000 EXPOSE 8000
# 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
USER appuser
# Use the entrypoint script as the container's command # Use the entrypoint script as the container's command
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+31 -37
View File
@@ -20,28 +20,6 @@ Uses Docker and Docker Compose for a production-ready environment with Gunicorn
- Configurable Template: - Configurable Template:
The applications title and logo can be dynamically configured via environment variables without rebuilding the image. The applications title and logo can be dynamically configured via environment variables without rebuilding the image.
### Project Structure
```
kiosk-guestbook/
├── app.py # Main Flask application code
├── Dockerfile # Dockerfile for building the image
├── docker-compose.yml # Docker Compose configuration (see deployment instructions below)
├── example.docker-compose.yml # Example Docker Compose file for deployment
├── example.env # Example environment variable file
├── entrypoint.sh # Entrypoint script that processes templates and starts Gunicorn
├── en.txt # Profanity list file (one banned word per line)
├── README.md # Project documentation
├── requirements.txt # Python dependencies (Flask, Gunicorn, etc.)
├── scripts/
│ └── guestbook_export.py # Script to export guest entries to CSV (e.g., for Mailchimp)
├── static/
│ └── images/
│ └── logo.png # Default logo for display in the application (configurable via env variable)
└── templates/
└── index.html.template # HTML template for the guestbook page (processed to index.html at runtime)
```
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -54,8 +32,8 @@ kiosk-guestbook/
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: 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:
``` ```bash
git clone <https://github.com/tmdinosaurcenter/kiosk-guestbook.git> git clone https://github.com/tmdinosaurcenter/kiosk-guestbook.git
cd kiosk-guestbook cd kiosk-guestbook
``` ```
@@ -66,12 +44,12 @@ If you dont wish to clone the entire repo, you can also download the two file
1. Copy Example Files: 1. Copy Example Files:
From the project root, copy the example files: From the project root, copy the example files:
``` ``` bash
cp example.docker-compose.yml docker-compose.yml cp example.docker-compose.yml docker-compose.yml
cp example.env .env cp example.env .env
``` ```
2. **Edit the `.env` File (Optional):** 2. **Edit the `.env` File (Optional)**
Modify `.env` to customize settings such as `SITE_TITLE`, `LOGO_URL`, `PORT`, etc. Modify `.env` to customize settings such as `SITE_TITLE`, `LOGO_URL`, `PORT`, etc.
@@ -83,16 +61,32 @@ Run the following command to pull (or use) the pre-built image and start the con
This command starts the container in detached mode, mounts the persistent volume for the SQLite database, and uses your environment variable settings. 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:** 4. **Access the Application**
Open your browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your `.env` file). Open your browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your `.env` file).
### Method 2: Running in Portainer ### Method 2: Running in Portainer
1. 1. **Copy Example Files**
2.
3. ```bash
4. 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 ## Logging and Monitoring
@@ -106,21 +100,21 @@ Open your browser and navigate to `http://<your-server-ip>:8000` (or the port sp
Access the API endpoint to export guest entries by navigating to: Access the API endpoint to export guest entries by navigating to:
`http://<your-server-ip>:8000/guests/api` `http://your-server-ip:8000/guests/api`
This endpoint can be integrated with on-prem automation tools like n8n. This endpoint can be integrated with on-prem automation tools like n8n.
## Additional Notes ## Additional Notes
- **Intranet-Only Deployment:** - **Intranet-Only Deployment**
This application is designed for internal use only and is not exposed to the public internet. This application is designed for internal use only and is not exposed to the public internet.
- **Database Persistence:** - **Database Persistence**
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt. The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
- **Production Considerations:** - **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. 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: ## License
This project is licensed under the [MIT License](LICENSE).
This project is licensed under the [MIT License](LICENSE).
+113 -51
View File
@@ -1,8 +1,11 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort 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 email_validator import validate_email, EmailNotValidError
import sqlite3 import sqlite3
import re
import logging import logging
import os import os
import re
# Set up logging # Set up logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -10,6 +13,7 @@ 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')
limiter = Limiter(get_remote_address, app=app, default_limits=[])
def load_banned_words(): def load_banned_words():
banned_words = set() banned_words = set()
@@ -33,18 +37,26 @@ def load_banned_words():
BANNED_WORDS = load_banned_words() BANNED_WORDS = load_banned_words()
def contains_banned_words(text): def contains_banned_words(text):
words = text.lower().split() lower = text.lower()
for word in words: # Whole-word check (punctuation-stripped) — catches exact matches
word_clean = word.strip(".,!?;:\"'") for word in lower.split():
if word_clean in BANNED_WORDS: 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 True
return False return False
def init_db(): # Each entry is a list of SQL statements for that schema version.
conn = sqlite3.connect(DATABASE) # To add a column or index in the future, append a new list — never modify existing entries.
c = conn.cursor() MIGRATIONS = [
c.execute(''' # v1 — initial schema
CREATE TABLE IF NOT EXISTS guests ( [
'''CREATE TABLE IF NOT EXISTS guests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL, first_name TEXT NOT NULL,
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
@@ -53,21 +65,53 @@ def init_db():
comment TEXT, comment TEXT,
newsletter_opt_in BOOLEAN DEFAULT 1, newsletter_opt_in BOOLEAN DEFAULT 1,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
) )''',
''') 'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)',
conn.commit() 'CREATE INDEX IF NOT EXISTS idx_guests_email ON guests (email)',
],
]
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() conn.close()
logger.info("Database initialized.")
def is_valid_email(email): def is_valid_email(email):
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$' try:
return re.match(pattern, email) validate_email(email, check_deliverability=False)
return True
except EmailNotValidError:
return False
@app.before_first_request with app.app_context():
def initialize_database(): migrate_db()
init_db()
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@limiter.limit("5 per minute", methods=["POST"])
def index(): def index():
error = None error = None
if request.method == 'POST': if request.method == 'POST':
@@ -90,32 +134,46 @@ def index():
logger.warning("Profanity detected in comment.") logger.warning("Profanity detected in comment.")
if error: if error:
conn = sqlite3.connect(DATABASE) try:
c = conn.cursor() conn = sqlite3.connect(DATABASE)
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC') c = conn.cursor()
guests = c.fetchall() c.execute('SELECT first_name, location FROM guests ORDER BY id DESC LIMIT 100')
conn.close() 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) return render_template('index.html', error=error, guests=guests)
conn = sqlite3.connect(DATABASE) try:
c = conn.cursor() conn = sqlite3.connect(DATABASE)
c.execute( c = conn.cursor()
''' c.execute(
INSERT INTO guests (first_name, last_name, email, location, comment, newsletter_opt_in) '''
VALUES (?, ?, ?, ?, ?, ?) INSERT INTO guests (first_name, last_name, email, location, comment, newsletter_opt_in)
''', VALUES (?, ?, ?, ?, ?, ?)
(first_name, last_name, email, location, comment, newsletter_opt_in) ''',
) (first_name, last_name, email, location, comment, newsletter_opt_in)
conn.commit() )
conn.close() conn.commit()
conn.close()
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) logger.info("Added guest: %s %s from %s", first_name, last_name, location)
return redirect(url_for('index')) return redirect(url_for('index'))
conn = sqlite3.connect(DATABASE) try:
c = conn.cursor() conn = sqlite3.connect(DATABASE)
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC') c = conn.cursor()
guests = c.fetchall() c.execute('SELECT first_name, location FROM guests ORDER BY id DESC LIMIT 100')
conn.close() guests = c.fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error loading guests: %s", e)
guests = []
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)
@@ -125,16 +183,20 @@ def api_guests():
if api_key != os.environ.get("API_KEY"): if api_key != os.environ.get("API_KEY"):
abort(403) abort(403)
conn = sqlite3.connect(DATABASE) try:
c = conn.cursor() conn = sqlite3.connect(DATABASE)
c.execute(''' c = conn.cursor()
SELECT first_name, last_name, email, location, comment, newsletter_opt_in, timestamp c.execute('''
FROM guests SELECT first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
WHERE email IS NOT NULL AND email != '' FROM guests
ORDER BY id DESC WHERE email IS NOT NULL AND email != ''
''') ORDER BY id DESC
rows = c.fetchall() ''')
conn.close() 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 = [ guests = [
{ {
@@ -151,6 +213,6 @@ def api_guests():
return jsonify(guests) return jsonify(guests)
if __name__ == '__main__': if __name__ == '__main__':
init_db() migrate_db()
logger.info("Starting development server at http://0.0.0.0:8000") logger.info("Starting development server at http://0.0.0.0:8000")
app.run(host='0.0.0.0', port=8000) app.run(host='0.0.0.0', port=8000)
+1 -1
View File
@@ -4,4 +4,4 @@
envsubst < /app/templates/index.html.template > /app/templates/index.html envsubst < /app/templates/index.html.template > /app/templates/index.html
# Start Gunicorn; using an environment variable for workers (default is 3) # Start Gunicorn; using an environment variable for workers (default is 3)
exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${WORKERS:-3} exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3}
+3 -1
View File
@@ -1,3 +1,5 @@
Flask==2.2.5 Flask>=3.1.3
Werkzeug>=3.0.6 Werkzeug>=3.0.6
Flask-Limiter>=3.0
email-validator>=2.0
gunicorn gunicorn
+2 -2
View File
@@ -1,8 +1,8 @@
import csv import csv
import os
import sqlite3 import sqlite3
# Update the database file path if needed. DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
DATABASE = 'guestbook.db'
EXPORT_FILE = 'mailchimp_export.csv' EXPORT_FILE = 'mailchimp_export.csv'
def export_guestbook_to_csv(): def export_guestbook_to_csv():
+28 -4
View File
@@ -23,16 +23,17 @@
.scrolling-content { .scrolling-content {
display: inline-block; display: inline-block;
padding: 10px; padding: 10px;
animation: scroll-left 20s linear infinite; font-size: 1.25rem;
animation: scroll-left linear infinite;
} }
@keyframes scroll-left { @keyframes scroll-left {
0% { 0% {
transform: translateX(100%); transform: translateX(0);
} }
100% { 100% {
transform: translateX(-100%); transform: translateX(-50%);
} }
} }
</style> </style>
@@ -97,16 +98,39 @@
</div> </div>
<!-- Scrolling Guest Entries at the Bottom --> <!-- Scrolling Guest Entries at the Bottom -->
<!-- Content is duplicated so the loop is seamless: animate 0 → -50% -->
<div class="scrolling-wrapper"> <div class="scrolling-wrapper">
<div class="scrolling-content"> <div class="scrolling-content">
{% for guest in guests %} {% 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] }} <strong>{{ guest[0] }}</strong> from {{ guest[1] }}
</span> </span>
{% endfor %} {% endfor %}
</div> </div>
</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 --> <!-- JavaScript to reveal the comment field -->
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {