mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 613a9dc590 | |||
| c371b9a04f | |||
| d57ba928c4 | |||
| c1db6ee692 | |||
| 523a9e22c2 | |||
| f5af8b556f | |||
| 211c94b8c8 | |||
| a7350bc3d5 | |||
| e3ed22a201 | |||
| d37887fb93 | |||
| 519911c8f5 | |||
| b20e118def | |||
| 6577a733c6 | |||
| 7914ac1ed7 | |||
| d1d2065da2 | |||
| 047f57513d | |||
| 3057201102 | |||
| 12bc0cd4a1 | |||
| bc8d4f9fe5 | |||
| aa7fefe497 | |||
| 898441af0c | |||
| 617aa5f028 | |||
| ecdcc044b7 | |||
| 9ad7128619 | |||
| 61a298a735 | |||
| 4d58e0f0a1 | |||
| 53741a4cbf | |||
| 4c691ab31a | |||
| 77c377ab51 | |||
| ae5002d407 | |||
| 5f71641cf0 | |||
| c1206a244c | |||
| 8230ae1c1c | |||
| c55037b37b | |||
| 36f8a01999 | |||
| 4f675fe74c | |||
| d5eac47ceb | |||
| 9ebac80f35 | |||
| 2d4eac6583 | |||
| 94d6690e57 | |||
| 4f0a7df22a |
@@ -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@v0.36.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 }}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Unfixed OS-level vulnerabilities in Debian 13 (trixie) base image.
|
||||
# No fix available upstream as of 2026-04-27; revisit when patches land.
|
||||
|
||||
# ncurses: buffer overflow (libncursesw6, libtinfo6, ncurses-base, ncurses-bin)
|
||||
CVE-2025-69720
|
||||
|
||||
# nghttp2: DoS via malformed HTTP/2 frames after session termination (libnghttp2-14)
|
||||
CVE-2026-27135
|
||||
|
||||
# systemd: arbitrary code execution / DoS via spurious IPC (libsystemd0, libudev1)
|
||||
CVE-2026-29111
|
||||
|
||||
# libcap: privilege escalation via TOCTOU race in cap_set_file() (libcap2)
|
||||
CVE-2026-4878
|
||||
|
||||
# gnutls: DoS via DTLS zero-length fragment (libgnutls30t64)
|
||||
CVE-2026-33845
|
||||
+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
|
||||
|
||||
@@ -98,9 +98,29 @@ Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (o
|
||||
|
||||
## 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.
|
||||
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. Authentication uses session cookies with an HTML login form — logging out fully invalidates the session so credentials are never cached by the browser.
|
||||
|
||||
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.
|
||||
Access requires `ADMIN_USER`, `ADMIN_PASSWORD`, and `SECRET_KEY` to be set in your `.env`. If either of the admin credentials are missing the interface returns 503. If `SECRET_KEY` is not set a default development key is used, which is insecure in production — always set your own.
|
||||
|
||||
### Generating a `SECRET_KEY`
|
||||
|
||||
Use Python to generate a cryptographically random key:
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Paste the output as the value for `SECRET_KEY` in your `.env`.
|
||||
|
||||
### User Roles
|
||||
|
||||
The bootstrap superadmin (set via `ADMIN_USER` / `ADMIN_PASSWORD`) can manage additional users at `/admin/users`:
|
||||
|
||||
| Role | View entries | Delete entries | Manage users |
|
||||
| ---------- | :----------: | :------------: | :----------: |
|
||||
| superadmin | ✓ | ✓ | ✓ |
|
||||
| admin | ✓ | ✓ | — |
|
||||
| viewer | ✓ | — | — |
|
||||
|
||||
## API Access
|
||||
|
||||
@@ -112,14 +132,28 @@ Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-
|
||||
|
||||
## 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:
|
||||
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 for the admin interface:
|
||||
|
||||
```env
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
```
|
||||
|
||||
Replace the placeholder values with your own credentials before deploying.
|
||||
As of **v2.3.0**, a `SECRET_KEY` is also required for session-based authentication:
|
||||
|
||||
```env
|
||||
SECRET_KEY=your-random-secret-key-here
|
||||
```
|
||||
|
||||
Generate one with:
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Replace all placeholder values with your own before deploying.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort, Response
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from functools import wraps
|
||||
import sqlite3
|
||||
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
|
||||
from flask_limiter import Limiter
|
||||
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, generate_csrf
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -14,7 +22,85 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
||||
|
||||
_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'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class User(UserMixin):
|
||||
"""Lightweight user object stored in the session."""
|
||||
def __init__(self, user_id, username, role):
|
||||
# user_id format: 's:<username>' for superadmin, 'u:<db_id>' for DB users
|
||||
self.id = user_id
|
||||
self.username = username
|
||||
self.role = role
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
if user_id.startswith('s:'):
|
||||
username = user_id[2:]
|
||||
admin_user = os.environ.get('ADMIN_USER')
|
||||
if admin_user and username == admin_user:
|
||||
return User(user_id, username, 'superadmin')
|
||||
return None
|
||||
if user_id.startswith('u:'):
|
||||
db_id = user_id[2:]
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
'SELECT id, username, role FROM users WHERE id = ?', (db_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return User(f'u:{row[0]}', row[1], row[2])
|
||||
except sqlite3.Error as e:
|
||||
logger.error("Database error in user_loader: %s", e)
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profanity filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_banned_words():
|
||||
banned_words = set()
|
||||
@@ -37,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
|
||||
@@ -52,6 +146,10 @@ def contains_banned_words(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 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 = [
|
||||
@@ -70,6 +168,15 @@ MIGRATIONS = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)',
|
||||
'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():
|
||||
@@ -111,6 +218,26 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@limiter.limit("5 per minute", methods=["POST"])
|
||||
def index():
|
||||
@@ -127,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)
|
||||
@@ -164,7 +299,14 @@ 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)
|
||||
return redirect(url_for('index'))
|
||||
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('thank_you', name=first_name))
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
@@ -178,34 +320,115 @@ def index():
|
||||
logger.info("Rendering index with %d guests.", len(guests))
|
||||
return render_template('index.html', error=error, guests=guests)
|
||||
|
||||
def require_admin_auth(f):
|
||||
@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:
|
||||
logger.error("ADMIN_USER and ADMIN_PASSWORD must be set to enable the admin interface.")
|
||||
abort(503)
|
||||
auth = request.authorization
|
||||
if not auth or auth.username != admin_user or auth.password != admin_password:
|
||||
return Response(
|
||||
'Authentication required.',
|
||||
401,
|
||||
{'WWW-Authenticate': 'Basic realm="Admin"'}
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thank-you page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/admin')
|
||||
@require_admin_auth
|
||||
def admin():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 25
|
||||
offset = (page - 1) * per_page
|
||||
@app.route('/thank-you')
|
||||
def thank_you():
|
||||
name = request.args.get('name', '').strip()
|
||||
if not name:
|
||||
return redirect(url_for('index'))
|
||||
offline = request.args.get('offline') == '1'
|
||||
site_title = os.environ.get('SITE_TITLE', 'Guestbook')
|
||||
logo_url = os.environ.get('LOGO_URL', '/static/images/logo.png')
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
total = c.execute('SELECT COUNT(*) FROM guests').fetchone()[0]
|
||||
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 for thank-you: %s", e)
|
||||
guests = []
|
||||
return render_template('thank_you.html', name=name, guests=guests,
|
||||
site_title=site_title, logo_url=logo_url,
|
||||
offline=offline)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin auth routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('admin'))
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
admin_user = os.environ.get('ADMIN_USER')
|
||||
admin_password = os.environ.get('ADMIN_PASSWORD')
|
||||
# Check superadmin first
|
||||
if admin_user and username == admin_user and password == admin_password:
|
||||
login_user(User(f's:{username}', username, 'superadmin'))
|
||||
logger.info("Superadmin '%s' logged in.", username)
|
||||
return redirect(request.args.get('next') or url_for('admin'))
|
||||
# Check DB users
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
'SELECT id, password_hash, role FROM users WHERE username = ?', (username,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row and check_password_hash(row[1], password):
|
||||
login_user(User(f'u:{row[0]}', username, row[2]))
|
||||
logger.info("User '%s' (role=%s) logged in.", username, row[2])
|
||||
return redirect(request.args.get('next') or url_for('admin'))
|
||||
except sqlite3.Error as e:
|
||||
logger.error("Database error during login: %s", e)
|
||||
error = 'Invalid username or password.'
|
||||
logger.warning("Failed login attempt for username '%s'.", username)
|
||||
return render_template('admin_login.html', error=error)
|
||||
|
||||
@app.route('/admin/logout')
|
||||
def admin_logout():
|
||||
logout_user()
|
||||
return redirect(url_for('admin_login'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/admin')
|
||||
@login_required
|
||||
def admin():
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 25
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Compute week/month boundaries in Mountain Time, convert to UTC for SQLite comparison
|
||||
now_mt = datetime.now(_DISPLAY_TZ)
|
||||
today_mt = now_mt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start_utc = (today_mt - timedelta(days=today_mt.weekday())).astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
month_start_utc = today_mt.replace(day=1).astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
row = c.execute('''
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) AS this_week,
|
||||
SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) AS this_month,
|
||||
SUM(CASE WHEN newsletter_opt_in THEN 1 ELSE 0 END) AS newsletter_count
|
||||
FROM guests
|
||||
''', (week_start_utc, month_start_utc)).fetchone()
|
||||
total, this_week, this_month, newsletter_count = row
|
||||
total = total or 0
|
||||
this_week = this_week or 0
|
||||
this_month = this_month or 0
|
||||
newsletter_count = newsletter_count or 0
|
||||
newsletter_pct = round(newsletter_count / total * 100) if total > 0 else 0
|
||||
c.execute('''
|
||||
SELECT id, first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
|
||||
FROM guests ORDER BY id DESC LIMIT ? OFFSET ?
|
||||
@@ -215,13 +438,26 @@ def admin():
|
||||
except sqlite3.Error as e:
|
||||
logger.error("Database error in admin: %s", e)
|
||||
guests = []
|
||||
total = 0
|
||||
total = this_week = this_month = newsletter_count = newsletter_pct = 0
|
||||
|
||||
stats = {
|
||||
'total': total,
|
||||
'week': this_week,
|
||||
'month': this_month,
|
||||
'newsletter_count': newsletter_count,
|
||||
'newsletter_pct': newsletter_pct,
|
||||
}
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages, total=total)
|
||||
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages,
|
||||
total=total, stats=stats)
|
||||
|
||||
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
|
||||
@require_admin_auth
|
||||
@login_required
|
||||
def admin_delete(entry_id):
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
if current_user.role == 'viewer':
|
||||
abort(403)
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
@@ -233,7 +469,116 @@ def admin_delete(entry_id):
|
||||
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/export.csv')
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def admin_export_csv():
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
if current_user.role == 'viewer':
|
||||
abort(403)
|
||||
import csv, io
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT id, first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
|
||||
FROM guests ORDER BY id ASC
|
||||
''')
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
logger.error("Database error in admin_export_csv: %s", e)
|
||||
abort(503)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(['id', 'first_name', 'last_name', 'email', 'location',
|
||||
'comment', 'newsletter_opt_in', 'timestamp_mountain'])
|
||||
for row in rows:
|
||||
ts = row[7]
|
||||
try:
|
||||
dt = datetime.strptime(str(ts), '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
||||
ts = dt.astimezone(_DISPLAY_TZ).strftime('%Y-%m-%d %H:%M')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
writer.writerow([row[0], row[1], row[2], row[3], row[4], row[5], int(row[6] or 0), ts])
|
||||
from flask import Response
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename="guestbook_export.csv"'}
|
||||
)
|
||||
|
||||
@app.route('/admin/users')
|
||||
@login_required
|
||||
def admin_users():
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
if current_user.role != 'superadmin':
|
||||
abort(403)
|
||||
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'])
|
||||
@login_required
|
||||
def admin_users_add():
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
if current_user.role != 'superadmin':
|
||||
abort(403)
|
||||
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'])
|
||||
@login_required
|
||||
def admin_users_delete(user_id):
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
if current_user.role != 'superadmin':
|
||||
abort(403)
|
||||
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'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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"):
|
||||
@@ -268,6 +613,50 @@ def api_guests():
|
||||
]
|
||||
return jsonify(guests)
|
||||
|
||||
@app.route('/api/csrf', methods=['GET'])
|
||||
@csrf.exempt
|
||||
@limiter.limit("30 per minute")
|
||||
def api_csrf():
|
||||
return jsonify({"csrf_token": generate_csrf()})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/manifest.webmanifest')
|
||||
@csrf.exempt
|
||||
def pwa_manifest():
|
||||
import json as _json
|
||||
site_title = os.environ.get('SITE_TITLE', 'Guestbook')
|
||||
logo_url = os.environ.get('LOGO_URL', '/static/images/logo.png')
|
||||
manifest = {
|
||||
"name": site_title,
|
||||
"short_name": site_title,
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3a9cb8",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": logo_url,
|
||||
"sizes": "500x500",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
from flask import Response
|
||||
return Response(_json.dumps(manifest), mimetype='application/manifest+json')
|
||||
|
||||
@app.route('/sw.js')
|
||||
@csrf.exempt
|
||||
def service_worker():
|
||||
response = app.send_static_file('sw.js')
|
||||
response.headers['Service-Worker-Allowed'] = '/'
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrate_db()
|
||||
logger.info("Starting development server at http://0.0.0.0:8000")
|
||||
|
||||
+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
|
||||
|
||||
@@ -12,3 +12,6 @@ SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
||||
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=
|
||||
+6
-3
@@ -1,5 +1,8 @@
|
||||
Flask>=3.1.3
|
||||
Werkzeug>=3.0.6
|
||||
Flask-Limiter>=3.0
|
||||
email-validator>=2.0
|
||||
Flask-WTF>=1.3.0
|
||||
Werkzeug>=3.1.8
|
||||
Flask-Limiter>=4.1.1
|
||||
Flask-Login>=0.6.3
|
||||
email-validator>=2.3.0
|
||||
gunicorn
|
||||
tzdata
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Offline queue for kiosk-guestbook.
|
||||
*
|
||||
* Intercepts the guestbook form submit via fetch. On network failure,
|
||||
* stores the submission in IndexedDB and shows an offline thank-you.
|
||||
* Replays queued entries on the `online` event and on each page load.
|
||||
*
|
||||
* Works on both the form page (/) and the thank-you page (/thank-you).
|
||||
* Background Sync is not used — it is unsupported in iOS Safari.
|
||||
*/
|
||||
|
||||
const OQ_DB_NAME = 'guestbook-offline-queue';
|
||||
const OQ_STORE = 'entries';
|
||||
const OQ_VERSION = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IndexedDB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function oqOpenDb() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var req = indexedDB.open(OQ_DB_NAME, OQ_VERSION);
|
||||
req.onupgradeneeded = function (e) {
|
||||
e.target.result.createObjectStore(OQ_STORE, { keyPath: 'id', autoIncrement: true });
|
||||
};
|
||||
req.onsuccess = function (e) { resolve(e.target.result); };
|
||||
req.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
}
|
||||
|
||||
function oqEnqueue(fields) {
|
||||
return oqOpenDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(OQ_STORE, 'readwrite');
|
||||
var store = tx.objectStore(OQ_STORE);
|
||||
store.add({ fields: fields, queued_at: new Date().toISOString() });
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function oqDequeue(id) {
|
||||
return oqOpenDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(OQ_STORE, 'readwrite');
|
||||
tx.objectStore(OQ_STORE).delete(id);
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function oqGetAll() {
|
||||
return oqOpenDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(OQ_STORE, 'readonly');
|
||||
var req = tx.objectStore(OQ_STORE).getAll();
|
||||
req.onsuccess = function (e) { resolve(e.target.result); };
|
||||
req.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collect form fields into a plain object (excludes csrf_token)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function oqCollectFields(form) {
|
||||
var fields = {};
|
||||
var fd = new FormData(form);
|
||||
fd.forEach(function (value, key) {
|
||||
if (key !== 'csrf_token') fields[key] = value;
|
||||
});
|
||||
// Explicitly record the newsletter checkbox so a missing key means opt-out
|
||||
var checkbox = form.querySelector('[name="newsletter_opt_in"]');
|
||||
if (checkbox && !checkbox.checked) {
|
||||
delete fields['newsletter_opt_in'];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue replay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var oqReplaying = false;
|
||||
|
||||
async function oqReplayQueue() {
|
||||
if (oqReplaying) return;
|
||||
oqReplaying = true;
|
||||
|
||||
var items;
|
||||
try {
|
||||
items = await oqGetAll();
|
||||
} catch (e) {
|
||||
oqReplaying = false;
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
oqReplaying = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch a fresh CSRF token once for the whole batch
|
||||
var token;
|
||||
try {
|
||||
var csrfRes = await fetch('/api/csrf');
|
||||
var csrfJson = await csrfRes.json();
|
||||
token = csrfJson.csrf_token;
|
||||
} catch (e) {
|
||||
// Still offline
|
||||
oqReplaying = false;
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('csrf_token', token);
|
||||
Object.keys(item.fields).forEach(function (k) {
|
||||
fd.append(k, item.fields[k]);
|
||||
});
|
||||
|
||||
var res = await fetch('/', { method: 'POST', body: fd });
|
||||
|
||||
if (res.ok) {
|
||||
await oqDequeue(item.id);
|
||||
} else if (res.status === 429) {
|
||||
// Rate-limited — leave remaining entries, try again later
|
||||
break;
|
||||
} else {
|
||||
// Server rejected (validation error etc.) — discard to unblock queue
|
||||
console.warn('oq: discarding entry', item.id, 'server returned', res.status);
|
||||
await oqDequeue(item.id);
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error again — stop, leave entries for next online event
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
oqReplaying = false;
|
||||
|
||||
// Update offline indicator if we're now fully synced
|
||||
var remaining;
|
||||
try { remaining = await oqGetAll(); } catch (e) { remaining = []; }
|
||||
if (!remaining.length) {
|
||||
oqSetIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline indicator (form page only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function oqSetIndicator(offline) {
|
||||
var el = document.getElementById('offline-indicator');
|
||||
if (!el) return;
|
||||
if (offline) {
|
||||
el.classList.remove('d-none');
|
||||
} else {
|
||||
el.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form submit intercept (form page only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function oqHandleSubmit(e) {
|
||||
e.preventDefault();
|
||||
var form = e.target;
|
||||
var fields = oqCollectFields(form);
|
||||
|
||||
try {
|
||||
var res = await fetch('/', { method: 'POST', body: new FormData(form) });
|
||||
// fetch follows redirects; res.url is the final URL (thank-you page on success)
|
||||
window.location.href = res.url;
|
||||
} catch (err) {
|
||||
// Network failure — queue and show offline thank-you
|
||||
try {
|
||||
await oqEnqueue(fields);
|
||||
} catch (dbErr) {
|
||||
console.error('oq: failed to enqueue', dbErr);
|
||||
}
|
||||
var name = fields['first_name'] || '';
|
||||
window.location.href = '/thank-you?name=' + encodeURIComponent(name) + '&offline=1';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap on DOMContentLoaded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Intercept form submit (form page only)
|
||||
var form = document.querySelector('form[action="/"]');
|
||||
if (form) {
|
||||
form.addEventListener('submit', oqHandleSubmit);
|
||||
}
|
||||
|
||||
// Sync indicator with current state
|
||||
if (!navigator.onLine) {
|
||||
oqSetIndicator(true);
|
||||
}
|
||||
|
||||
// Replay queue on reconnect
|
||||
window.addEventListener('online', function () {
|
||||
oqSetIndicator(false);
|
||||
oqReplayQueue();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', function () {
|
||||
oqSetIndicator(true);
|
||||
});
|
||||
|
||||
// Replay any previously queued items on page load
|
||||
if (navigator.onLine) {
|
||||
oqReplayQueue();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const CACHE_NAME = 'guestbook-v2';
|
||||
const STATIC_ASSETS = [
|
||||
'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css',
|
||||
'https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js',
|
||||
'https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap',
|
||||
'/static/images/logo.png',
|
||||
'/static/offline-queue.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.addAll(STATIC_ASSETS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Cache-first for CDN and local static assets
|
||||
const isStatic = STATIC_ASSETS.includes(event.request.url) ||
|
||||
(url.origin === self.location.origin && url.pathname.startsWith('/static/'));
|
||||
|
||||
if (isStatic) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(response => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-only for all app routes (/, /admin/*, /api/*, /manifest.webmanifest)
|
||||
// No caching of authenticated or dynamic content
|
||||
});
|
||||
+116
-41
@@ -2,55 +2,122 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Guestbook Admin</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { overscroll-behavior: none; }
|
||||
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
input, select, textarea { font-size: 16px !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">Guestbook Admin</h1>
|
||||
<span class="text-muted">{{ total }} total entries</span>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted">{{ current_user.username }}</span>
|
||||
{% if current_user.role != 'viewer' %}
|
||||
<a href="{{ url_for('admin_export_csv') }}" class="btn btn-outline-success btn-sm">Export CSV</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'superadmin' %}
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary btn-sm">Manage Users</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
|
||||
</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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No entries found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
||||
<span class="badge bg-primary" style="font-size:0.9rem;">{{ stats.total }} total</span>
|
||||
<span class="badge bg-secondary">{{ stats.week }} this week</span>
|
||||
<span class="badge bg-secondary">{{ stats.month }} this month</span>
|
||||
<span class="badge bg-success">{{ stats.newsletter_count }} newsletter ({{ stats.newsletter_pct }}%)</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card list (phones) -->
|
||||
<div class="d-md-none">
|
||||
{% for g in guests %}
|
||||
<div class="card mb-2 shadow-sm">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{{ g[1] }} {{ g[2] }}</strong>
|
||||
<div class="text-muted small">{{ g[4] }} · {{ g[7] | localtime }}</div>
|
||||
<div class="small mt-1">Newsletter: {{ 'Yes' if g[6] else 'No' }}</div>
|
||||
{% if g[3] %}
|
||||
<div class="text-muted small">{{ g[3] }}</div>
|
||||
{% endif %}
|
||||
{% if g[5] %}
|
||||
<div class="text-muted small fst-italic">{{ g[5] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 ms-2">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center">No entries found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Desktop table (hidden on phones) -->
|
||||
<div class="d-none d-md-block">
|
||||
<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] | 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 %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No entries found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
@@ -71,5 +138,13 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Guestbook Admin — Login</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { overscroll-behavior: none; }
|
||||
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
input.form-control { font-size: 16px !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-5" style="max-width: 400px;">
|
||||
<h1 class="h4 mb-4 text-center">Admin Login</h1>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if error %}
|
||||
<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"
|
||||
autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Log In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Guestbook Admin — Users</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { overscroll-behavior: none; }
|
||||
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
input.form-control, select.form-select { font-size: 16px !important; }
|
||||
</style>
|
||||
</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>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin') }}" class="btn btn-outline-secondary btn-sm">Back to Entries</a>
|
||||
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
|
||||
</div>
|
||||
</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') }}">
|
||||
<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 />
|
||||
</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] }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<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>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,12 +3,48 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>${SITE_TITLE}</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="${SITE_TITLE}" />
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
|
||||
/* PWA / iOS kiosk */
|
||||
html, body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Prevent iOS auto-zoom on input focus (requires >= 16px) */
|
||||
input.form-control,
|
||||
textarea.form-control,
|
||||
select.form-select {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* Scrolling marquee styles */
|
||||
.scrolling-wrapper {
|
||||
overflow: hidden;
|
||||
@@ -18,6 +54,7 @@
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.scrolling-content {
|
||||
@@ -52,6 +89,10 @@
|
||||
Providing your email is optional, but it helps us follow up if needed.
|
||||
</div>
|
||||
|
||||
<div id="offline-indicator" class="alert alert-warning d-none" role="status">
|
||||
No internet connection — your entry will be saved and submitted automatically when reconnected.
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
@@ -59,19 +100,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 +126,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>
|
||||
@@ -160,6 +202,17 @@
|
||||
<!-- Bootstrap JS (optional) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"></script>
|
||||
|
||||
<script src="/static/offline-queue.js"></script>
|
||||
|
||||
<!-- Service worker registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta http-equiv="refresh" content="4;url=/" />
|
||||
<title>{{ site_title }}</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="{{ site_title }}" />
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.scrolling-wrapper {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.scrolling-content {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
font-size: 1.25rem;
|
||||
animation: scroll-left linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scroll-left {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.thank-you-message {
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container mt-5 mb-5">
|
||||
<header class="d-flex align-items-center mb-4">
|
||||
<img src="{{ logo_url }}" alt="Logo" class="me-3" style="height: 50px;" />
|
||||
<h1 class="h3 mb-0">{{ site_title }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<div class="alert alert-success py-4" role="alert">
|
||||
<p class="thank-you-message mb-1">Thanks, {{ name }}!</p>
|
||||
{% if offline %}
|
||||
<p class="mb-0">You’re currently offline — your entry has been saved and will sync automatically when reconnected.</p>
|
||||
{% else %}
|
||||
<p class="mb-0">Your visit has been recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-muted mt-3">
|
||||
Returning to the form in <span id="countdown">4</span>...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrolling Guest Entries at the Bottom -->
|
||||
<div class="scrolling-wrapper">
|
||||
<div class="scrolling-content">
|
||||
{% for guest in guests %}
|
||||
<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>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const pixelsPerSecond = 80;
|
||||
const content = document.querySelector(".scrolling-content");
|
||||
function updateScrollSpeed() {
|
||||
const oneCopyWidth = content.offsetWidth / 2;
|
||||
content.style.animationDuration = (oneCopyWidth / pixelsPerSecond) + "s";
|
||||
}
|
||||
updateScrollSpeed();
|
||||
window.addEventListener("resize", updateScrollSpeed);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var n = 4;
|
||||
var el = document.getElementById('countdown');
|
||||
var timer = setInterval(function () {
|
||||
n--;
|
||||
if (el) el.textContent = n;
|
||||
if (n <= 0) {
|
||||
clearInterval(timer);
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/static/offline-queue.js"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user