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
.vscode/
# Claude Code
.claude/
.env
docker-compose.yml
+8
View File
@@ -24,5 +24,13 @@ ENV FLASK_ENV=production
# Expose the port (Gunicorn will run on 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
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:
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
### 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:
```
git clone <https://github.com/tmdinosaurcenter/kiosk-guestbook.git>
```bash
git clone https://github.com/tmdinosaurcenter/kiosk-guestbook.git
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:
From the project root, copy the example files:
```
``` bash
cp example.docker-compose.yml docker-compose.yml
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.
@@ -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.
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).
### Method 2: Running in Portainer
1.
2.
3.
4.
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
@@ -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:
`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.
## Additional Notes
- **Intranet-Only Deployment:**
- **Intranet-Only Deployment**
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.
- **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.
## License:
This project is licensed under the [MIT License](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_limiter import Limiter
from flask_limiter.util import get_remote_address
from email_validator import validate_email, EmailNotValidError
import sqlite3
import re
import logging
import os
import re
# Set up logging
logging.basicConfig(level=logging.INFO)
@@ -10,6 +13,7 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
limiter = Limiter(get_remote_address, app=app, default_limits=[])
def load_banned_words():
banned_words = set()
@@ -33,18 +37,26 @@ def load_banned_words():
BANNED_WORDS = load_banned_words()
def contains_banned_words(text):
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():
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,
@@ -53,21 +65,53 @@ def init_db():
comment TEXT,
newsletter_opt_in BOOLEAN DEFAULT 1,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
)''',
'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)',
'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()
logger.info("Database initialized.")
def is_valid_email(email):
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():
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':
@@ -90,32 +134,46 @@ def index():
logger.warning("Profanity detected in comment.")
if error:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
guests = c.fetchall()
conn.close()
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
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)
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute(
'''
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()
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute(
'''
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()
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'))
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
guests = c.fetchall()
conn.close()
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
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 = []
logger.info("Rendering index with %d guests.", len(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"):
abort(403)
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()
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 = [
{
@@ -151,6 +213,6 @@ def api_guests():
return jsonify(guests)
if __name__ == '__main__':
init_db()
migrate_db()
logger.info("Starting development server at http://0.0.0.0: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
# 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
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():
+28 -4
View File
@@ -23,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>
@@ -97,16 +98,39 @@
</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 () {