mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f0a7df22a | |||
| b2e7eeb570 | |||
| 047f1a8c8b | |||
| c2b6c1b460 | |||
| e733e7b092 | |||
| 9fe3bc43d0 | |||
| a0e6042300 | |||
| 05bcf10614 | |||
| 78ef3eeb85 | |||
| 46dca45e04 | |||
| 2dc276f098 | |||
| e6d742f92e | |||
| e0d72f8057 | |||
| d98dd1518b | |||
| 920463b4a7 | |||
| a178e6193b | |||
| 0c4d3ab15d | |||
| 3e17574fe6 | |||
| 0c8491ce7a | |||
| 1a0a1371bc | |||
| d260bc6f9f | |||
| 412d373421 |
@@ -30,3 +30,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
|
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
|
||||||
docker push 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"
|
||||||
|
|||||||
@@ -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
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|||||||
+10
-2
@@ -4,8 +4,8 @@ FROM python:3.9-slim
|
|||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies (including gettext for envsubst)
|
# Install system dependencies (including gettext for envsubst and gosu for privilege dropping)
|
||||||
RUN apt-get update && apt-get install -y gettext && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gettext gosu && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -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
|
||||||
|
# Entrypoint runs as root, fixes volume permissions, then drops to appuser via gosu
|
||||||
|
|
||||||
# Use the entrypoint script as the container's command
|
# Use the entrypoint script as the container's command
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -20,28 +20,6 @@ Uses Docker and Docker Compose for a production-ready environment with Gunicorn
|
|||||||
- Configurable Template:
|
- Configurable Template:
|
||||||
The application’s title and logo can be dynamically configured via environment variables without rebuilding the image.
|
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
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### 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:
|
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:
|
||||||
|
|
||||||
```
|
```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 don’t 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
|
||||||
|
|
||||||
@@ -102,25 +96,42 @@ Open your browser and navigate to `http://<your-server-ip>:8000` (or the port sp
|
|||||||
|
|
||||||
`docker-compose logs -f`
|
`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
|
## API Access
|
||||||
|
|
||||||
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/api/guests`
|
||||||
|
|
||||||
This endpoint can be integrated with on-prem automation tools like n8n.
|
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
|
## 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 server’s specifications.
|
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:
|
## License
|
||||||
This project is licensed under the [MIT License](LICENSE).
|
|
||||||
|
|
||||||
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
|
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 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 +15,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 +39,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 +67,62 @@ 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)',
|
||||||
|
],
|
||||||
|
# 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()
|
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,51 +145,216 @@ 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)
|
||||||
|
|
||||||
|
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'])
|
@app.route('/api/guests', methods=['GET'])
|
||||||
def api_guests():
|
def api_guests():
|
||||||
api_key = request.headers.get('X-API-Key')
|
api_key = request.headers.get('X-API-Key')
|
||||||
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 +371,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)
|
||||||
|
|||||||
+8
-3
@@ -1,7 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/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
|
# 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
|
envsubst < /app/templates/index.html.template > /app/templates/index.html
|
||||||
|
|
||||||
# Start Gunicorn; using an environment variable for workers (default is 3)
|
# Drop to appuser and start Gunicorn
|
||||||
exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${WORKERS:-3}
|
exec gosu appuser gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3}
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ PID=1000
|
|||||||
GID=1000
|
GID=1000
|
||||||
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
||||||
LOGO_URL="/static/images/logo.png"
|
LOGO_URL="/static/images/logo.png"
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
+3
-1
@@ -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
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user