Compare commits

...

21 Commits
1.0.0 ... main

Author SHA1 Message Date
fbfa935706 Removed "Project Structure" section of README.md 2025-04-04 18:48:20 -06:00
6ca863e15d Portainer instructions & markdown lint of README.md 2025-04-04 16:28:34 -06:00
df10a307f1 - 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
73984635e1 Flask has needs to see that the index.html template is in /app/templates/, not just /templates/ 2025-04-04 15:04:46 -06:00
e6d73c8c0a Move entrypoint.sh into top-level to kickstart docker image building 2025-04-04 14:59:05 -06:00
f85c05ca9a Fix location of DATABASE_PATH in the example.env 2025-04-04 14:55:38 -06:00
e25cdca466 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
9f462c4c0a Removed development Dockerfile, updated .gitignore to exclude docker-compose and added new example.docker-compose.yml 2025-04-04 14:28:26 -06:00
f4466b8a67
Delete 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
c1f9ab05b4 Push the Docker image to Docker Hub 2025-04-04 14:07:11 -06:00
61a45200a5
Delete .env
Just making the example.env available to the public.
2025-04-04 13:51:14 -06:00
Steve Dogiakos
ab83c39c91 Created example.env so I don't have to keep messing
with my setup.
2025-04-04 13:50:13 -06:00
bd706c8a81
Create LICENSE
Adding the MIT license to the project
2025-04-02 19:47:44 -06:00
de6938d278
Update docker-image.yml
changed secrete to vars for DOCKER_USERNAME
2025-04-02 19:36:36 -06:00
e0a0c397cc
Update README.md
Added section about API use
2025-04-02 19:28:21 -06:00
db2acddbc6
trying a docker action workflow
Added Docker secrets so hopefully it will run now.
2025-04-02 18:16:53 -06:00
Steve Dogiakos
2ed6e7c7b0 accidentally introduced duplicate email fields in the last commit. this commits fixes it. 2025-04-02 15:48:56 -06:00
Steve Dogiakos
9b244e1661 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
3411b48dcf deleted workflow because I don't want to sign into dockerhub 2025-04-02 09:44:59 -06:00
Steve Dogiakos
35ac31be7f 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
babbc43806
Create docker-image.yml 2025-04-02 08:38:24 -06:00
11 changed files with 247 additions and 119 deletions

32
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,32 @@
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

2
.gitignore vendored
View File

@ -184,3 +184,5 @@ cython_debug/
# VS Code
.vscode/
.env
docker-compose.yml

View File

@ -4,18 +4,25 @@ FROM python:3.9-slim
# Set the working directory
WORKDIR /app
# Install dependencies
# Install system dependencies (including gettext for envsubst)
RUN apt-get update && apt-get install -y gettext && 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"]
# Use the entrypoint script as the container's command
CMD ["/entrypoint.sh"]

21
LICENSE Normal file
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.

132
README.md
View File

@ -8,38 +8,17 @@ 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).
- Input Validation:
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,95 @@ 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`
## API Access
Access the API endpoint to export guest entries by navigating to:
`http://your-server-ip:8000/guests/api`
This endpoint can be integrated with on-prem automation tools like n8n.
## 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).

74
app.py
View File

@ -1,24 +1,17 @@
from flask import Flask, render_template, request, redirect, url_for
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
import sqlite3
import re
import logging
import os
# 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')
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,15 +26,13 @@ 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(".,!?;:\"'")
@ -50,7 +41,6 @@ def contains_banned_words(text):
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('''
@ -61,6 +51,7 @@ def init_db():
email TEXT,
location TEXT NOT NULL,
comment TEXT,
newsletter_opt_in BOOLEAN DEFAULT 1,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
@ -69,35 +60,34 @@ def init_db():
logger.info("Database initialized.")
def is_valid_email(email):
"""Simple regex-based email validation."""
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return re.match(pattern, email)
@app.before_first_request
def initialize_database():
"""Ensure the database is initialized before handling the first request."""
init_db()
@app.route('/', methods=['GET', '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:
conn = sqlite3.connect(DATABASE)
@ -110,25 +100,57 @@ def index():
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)
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.
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
guests = c.fetchall()
conn.close()
logger.info("Rendering guestbook page with %d entries.", len(guests))
logger.info("Rendering index with %d guests.", len(guests))
return render_template('index.html', error=error, guests=guests)
@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)
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()
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.")
logger.info("Starting development server at http://0.0.0.0:8000")
app.run(host='0.0.0.0', port=8000)

7
entrypoint.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/sh
# Process index.html.template to create index.html
# Adjust the path if your template is located somewhere else
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}

View File

@ -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:

View File

@ -3,9 +3,10 @@ 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"

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"]

View File

@ -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 {
@ -40,8 +41,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,25 +60,38 @@
<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>
@ -96,24 +110,26 @@
<!-- 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>