mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78ef3eeb85 | |||
| 46dca45e04 | |||
| 2dc276f098 | |||
| e6d742f92e | |||
| e0d72f8057 | |||
| d98dd1518b | |||
| 920463b4a7 | |||
| a178e6193b | |||
| 0c4d3ab15d | |||
| 3e17574fe6 | |||
| 0c8491ce7a | |||
| 1a0a1371bc | |||
| d260bc6f9f | |||
| 412d373421 |
@@ -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 }}
|
||||
@@ -184,5 +184,8 @@ cython_debug/
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
.env
|
||||
docker-compose.yml
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -20,28 +20,6 @@ Uses Docker and Docker Compose for a production-ready environment with Gunicorn
|
||||
- Configurable Template:
|
||||
The application’s 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, you’ll 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 don’t 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 server’s specifications.
|
||||
|
||||
## License:
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user