mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc8d4f9fe5 | |||
| aa7fefe497 | |||
| 898441af0c | |||
| 617aa5f028 | |||
| ecdcc044b7 | |||
| 9ad7128619 | |||
| 61a298a735 | |||
| 4d58e0f0a1 | |||
| 53741a4cbf | |||
| 4c691ab31a | |||
| 77c377ab51 | |||
| ae5002d407 | |||
| 5f71641cf0 | |||
| c1206a244c | |||
| 8230ae1c1c | |||
| c55037b37b | |||
| 36f8a01999 | |||
| 4f675fe74c | |||
| d5eac47ceb | |||
| 9ebac80f35 |
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in the system
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Description
|
||||
|
||||
A clear and concise description of the bug.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See the error.
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
Explain what you expected to happen.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Add screenshots if applicable.
|
||||
|
||||
### Environment
|
||||
|
||||
- OS: [e.g., Windows, macOS, Linux]
|
||||
- Browser: [e.g., Chrome, Firefox]
|
||||
- Version: [e.g., 1.0.0]
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,22 @@
|
||||
blank_issues_enabled: false
|
||||
issue_templates:
|
||||
- name: "Bug Report"
|
||||
description: "Report a bug in the system."
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body: "./ISSUE_TEMPLATE/bug_report.md"
|
||||
- name: "Feature Request"
|
||||
description: "Propose a new feature or improvement."
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body: "./ISSUE_TEMPLATE/feature_request.md"
|
||||
- name: "Documentation"
|
||||
description: "Suggest updates or additions to the documentation."
|
||||
title: "[Docs]: "
|
||||
labels: ["documentation"]
|
||||
body: "./ISSUE_TEMPLATE/documentation.md"
|
||||
- name: "General Report"
|
||||
description: "Provide general feedback or inquiries."
|
||||
title: "[General]: "
|
||||
labels: ["general"]
|
||||
body: "./ISSUE_TEMPLATE/general_report.md"
|
||||
@@ -0,0 +1,23 @@
|
||||
--
|
||||
name: Documentation
|
||||
about: Suggest updates or additions to documentation
|
||||
title: "[Docs]: "
|
||||
labels: documentation
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Documentation Update
|
||||
|
||||
What part of the documentation needs to be updated or added?
|
||||
|
||||
### Why Is This Needed?
|
||||
|
||||
Explain the importance of this update.
|
||||
|
||||
### Suggested Changes
|
||||
|
||||
Provide a detailed description of the changes.
|
||||
|
||||
### Additional Context
|
||||
|
||||
Include any related resources.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Feature Description
|
||||
|
||||
What feature would you like to see?
|
||||
|
||||
### Why Is This Needed?
|
||||
|
||||
Explain the problem or need for this feature.
|
||||
|
||||
### Suggested Solutions
|
||||
|
||||
Describe how this feature could be implemented.
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any relevant screenshots, links, or resources.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: General Report
|
||||
about: Provide general feedback or inquiries
|
||||
title: "[General]: "
|
||||
labels: general
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Feedback or Inquiry
|
||||
|
||||
Provide your feedback or inquiry.
|
||||
|
||||
### Additional Information
|
||||
|
||||
Add any other relevant details here.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Dependabot version updates
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
|
||||
# Python dependencies — requirements.txt
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
# Docker base image — Dockerfile
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
# GitHub Actions workflow dependencies
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
@@ -9,12 +9,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to DockerHub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -25,6 +25,13 @@ jobs:
|
||||
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: Scan image for vulnerabilities
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.IMAGE_TAG }}
|
||||
format: table
|
||||
exit-code: '1'
|
||||
severity: CRITICAL,HIGH
|
||||
- name: Push the Docker image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
todo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: alstr/todo-to-issue-action@v5
|
||||
with:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Use a lightweight Python image
|
||||
FROM python:3.9-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -2,6 +2,9 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
|
||||
@@ -10,6 +13,7 @@ from flask_limiter.util import get_remote_address
|
||||
from flask_login import (
|
||||
LoginManager, UserMixin, login_user, logout_user, login_required, current_user
|
||||
)
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
# Set up logging
|
||||
@@ -18,9 +22,42 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
_secret_key = os.environ.get('SECRET_KEY')
|
||||
if not _secret_key:
|
||||
raise RuntimeError("SECRET_KEY environment variable must be set")
|
||||
app.secret_key = _secret_key
|
||||
|
||||
limiter = Limiter(get_remote_address, app=app, default_limits=[])
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE='Lax',
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=8),
|
||||
)
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
if request.path.startswith('/admin'):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
_DISPLAY_TZ = ZoneInfo('America/Denver')
|
||||
|
||||
@app.template_filter('localtime')
|
||||
def localtime_filter(value):
|
||||
if not value:
|
||||
return value
|
||||
try:
|
||||
dt = datetime.strptime(str(value), '%Y-%m-%d %H:%M:%S')
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(_DISPLAY_TZ).strftime('%Y-%m-%d %H:%M')
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = 'admin_login'
|
||||
@@ -86,6 +123,14 @@ def load_banned_words():
|
||||
|
||||
BANNED_WORDS = load_banned_words()
|
||||
|
||||
FIELD_MAX = {
|
||||
'first_name': 100,
|
||||
'last_name': 100,
|
||||
'email': 254,
|
||||
'location': 100,
|
||||
'comment': 2000,
|
||||
}
|
||||
|
||||
def contains_banned_words(text):
|
||||
lower = text.lower()
|
||||
# Whole-word check (punctuation-stripped) — catches exact matches
|
||||
@@ -173,6 +218,22 @@ def is_valid_email(email):
|
||||
with app.app_context():
|
||||
migrate_db()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fire_webhook(payload):
|
||||
url = os.environ.get("WEBHOOK_URL", "")
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
import urllib.request, json as _json
|
||||
data = _json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
logger.warning("Webhook delivery failed: %s", e)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -193,6 +254,14 @@ def index():
|
||||
if not (first_name and last_name and location):
|
||||
error = "First name, last name, and location are required."
|
||||
logger.warning("Missing required fields.")
|
||||
elif (len(first_name) > FIELD_MAX['first_name'] or
|
||||
len(last_name) > FIELD_MAX['last_name'] or
|
||||
len(location) > FIELD_MAX['location']):
|
||||
error = "A required field exceeds the maximum allowed length."
|
||||
elif email and len(email) > FIELD_MAX['email']:
|
||||
error = "Email address is too long."
|
||||
elif comment and len(comment) > FIELD_MAX['comment']:
|
||||
error = f"Comment is too long (max {FIELD_MAX['comment']:,} characters)."
|
||||
elif email and not is_valid_email(email):
|
||||
error = "Invalid email address."
|
||||
logger.warning("Invalid email: %s", email)
|
||||
@@ -230,6 +299,13 @@ def index():
|
||||
error="Unable to save your entry. Please try again.",
|
||||
guests=[])
|
||||
logger.info("Added guest: %s %s from %s", first_name, last_name, location)
|
||||
threading.Thread(target=_fire_webhook, args=({
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"email": email,
|
||||
"location": location,
|
||||
"newsletter_opt_in": newsletter_opt_in,
|
||||
},), daemon=True).start()
|
||||
return redirect(url_for('index'))
|
||||
|
||||
try:
|
||||
@@ -252,6 +328,7 @@ def _admin_configured():
|
||||
return bool(os.environ.get('ADMIN_USER') and os.environ.get('ADMIN_PASSWORD'))
|
||||
|
||||
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("10 per minute", methods=["POST"])
|
||||
def admin_login():
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
@@ -407,6 +484,8 @@ def admin_users_delete(user_id):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/api/guests', methods=['GET'])
|
||||
@limiter.limit("100 per hour")
|
||||
@csrf.exempt
|
||||
def api_guests():
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key != os.environ.get("API_KEY"):
|
||||
|
||||
+7
-1
@@ -1,4 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 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.
|
||||
@@ -9,4 +11,8 @@ chown -R appuser:appuser "$DATA_DIR"
|
||||
envsubst < /app/templates/index.html.template > /app/templates/index.html
|
||||
|
||||
# Drop to appuser and start Gunicorn
|
||||
exec gosu appuser gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3}
|
||||
exec gosu appuser gunicorn \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers ${GUNICORN_WORKERS:-3} \
|
||||
--timeout 30 \
|
||||
app:app
|
||||
|
||||
@@ -13,3 +13,5 @@ LOGO_URL="/static/images/logo.png"
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
SECRET_KEY=change-this-to-a-random-secret-key
|
||||
# Optional: POST new signups as JSON to this URL (e.g. an n8n Webhook node)
|
||||
WEBHOOK_URL=
|
||||
@@ -1,6 +1,8 @@
|
||||
Flask>=3.1.3
|
||||
Flask-WTF>=1.2
|
||||
Werkzeug>=3.0.6
|
||||
Flask-Limiter>=3.0
|
||||
Flask-Login>=0.6
|
||||
email-validator>=2.0
|
||||
gunicorn
|
||||
tzdata
|
||||
@@ -42,11 +42,12 @@
|
||||
<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 class="text-nowrap">{{ g[7] | localtime }}</td>
|
||||
<td>
|
||||
{% if current_user.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] }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('admin_login', next=request.args.get('next', '')) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<div class="card-header">Add User</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin_users_add') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<input type="text" name="username" class="form-control" placeholder="Username" required />
|
||||
@@ -57,6 +58,7 @@
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin_users_delete', user_id=u[0]) }}"
|
||||
onsubmit="return confirm('Remove user {{ u[1] }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" class="btn btn-danger btn-sm">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -8,7 +8,18 @@
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Vollkorn', serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Scrolling marquee styles */
|
||||
.scrolling-wrapper {
|
||||
overflow: hidden;
|
||||
@@ -59,19 +70,20 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/" class="mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<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" maxlength="100" 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" maxlength="100" 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" maxlength="254" />
|
||||
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
|
||||
@@ -84,13 +96,13 @@
|
||||
|
||||
<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" maxlength="100" 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>
|
||||
<textarea class="form-control" id="comment" name="comment" rows="3" maxlength="2000"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
|
||||
Reference in New Issue
Block a user