mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f0a7df22a | |||
| b2e7eeb570 | |||
| 047f1a8c8b | |||
| c2b6c1b460 | |||
| e733e7b092 | |||
| 9fe3bc43d0 | |||
| a0e6042300 | |||
| 05bcf10614 |
@@ -30,3 +30,15 @@ jobs:
|
||||
run: |
|
||||
docker tag $IMAGE_TAG 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"
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ FROM python:3.9-slim
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (including gettext for envsubst)
|
||||
RUN apt-get update && apt-get install -y gettext && rm -rf /var/lib/apt/lists/*
|
||||
# Install system dependencies (including gettext for envsubst and gosu for privilege dropping)
|
||||
RUN apt-get update && apt-get install -y gettext gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
@@ -30,7 +30,7 @@ ARG UID=1000
|
||||
ARG GID=1000
|
||||
RUN groupadd -g ${GID} appuser && useradd -u ${UID} -g ${GID} -s /bin/sh -M appuser
|
||||
RUN chown -R appuser:appuser /app /entrypoint.sh
|
||||
USER appuser
|
||||
# Entrypoint runs as root, fixes volume permissions, then drops to appuser via gosu
|
||||
|
||||
# Use the entrypoint script as the container's command
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -96,13 +96,30 @@ Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (o
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 logging
|
||||
import os
|
||||
@@ -69,6 +71,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():
|
||||
@@ -177,6 +188,153 @@ def index():
|
||||
logger.info("Rendering index with %d guests.", len(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'])
|
||||
def api_guests():
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
|
||||
+8
-3
@@ -1,7 +1,12 @@
|
||||
#!/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
|
||||
# Adjust the path if your template is located somewhere else
|
||||
envsubst < /app/templates/index.html.template > /app/templates/index.html
|
||||
|
||||
# Start Gunicorn; using an environment variable for workers (default is 3)
|
||||
exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3}
|
||||
# Drop to appuser and start Gunicorn
|
||||
exec gosu appuser gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3}
|
||||
|
||||
@@ -10,3 +10,5 @@ PID=1000
|
||||
GID=1000
|
||||
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
||||
LOGO_URL="/static/images/logo.png"
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user