8 Commits

Author SHA1 Message Date
steve 4f0a7df22a feat: add role-based access control with database-backed users 2026-03-10 10:29:42 -06:00
steve b2e7eeb570 feat: add hardened HTTP Basic Auth for admin interface 2026-03-10 10:07:09 -06:00
steve 047f1a8c8b feat: add paginated admin interface for viewing and deleting entries 2026-03-10 09:57:28 -06:00
steve c2b6c1b460 fix: add Bearer token authentication to ntfy notification 2026-03-09 23:47:01 -06:00
steve e733e7b092 fix: enable verbose curl output for ntfy debugging 2026-03-09 23:41:46 -06:00
steve 9fe3bc43d0 chore: add TODO for admin interface 2026-03-09 23:37:13 -06:00
steve a0e6042300 feat: add ntfy push notification on successful Docker Hub push 2026-03-09 23:16:54 -06:00
steve 05bcf10614 fix: resolve volume permission error for non-root container user
Entrypoint now runs as root, chowns the data directory to appuser,
then drops privileges via gosu before starting Gunicorn. This prevents
sqlite3.OperationalError on mounted volumes owned by root.
2026-03-09 23:07:49 -06:00
8 changed files with 361 additions and 10 deletions
+12
View File
@@ -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
View File
@@ -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"]
+19 -2
View File
@@ -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
+159 -1
View File
@@ -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
View File
@@ -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}
+2
View File
@@ -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
+82
View File
@@ -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>
+75
View File
@@ -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>