mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc8d4f9fe5 | |||
| aa7fefe497 | |||
| 898441af0c | |||
| 617aa5f028 | |||
| ecdcc044b7 | |||
| 9ad7128619 | |||
| 61a298a735 | |||
| 4d58e0f0a1 | |||
| 53741a4cbf | |||
| 4c691ab31a | |||
| 77c377ab51 | |||
| ae5002d407 | |||
| 5f71641cf0 | |||
| c1206a244c | |||
| 8230ae1c1c | |||
| c55037b37b | |||
| 36f8a01999 | |||
| 4f675fe74c | |||
| d5eac47ceb | |||
| 9ebac80f35 | |||
| 2d4eac6583 | |||
| 94d6690e57 | |||
| 4f0a7df22a | |||
| b2e7eeb570 | |||
| 047f1a8c8b | |||
| c2b6c1b460 | |||
| e733e7b092 | |||
| 9fe3bc43d0 | |||
| a0e6042300 | |||
| 05bcf10614 |
@@ -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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Log in to DockerHub
|
- name: Log in to DockerHub
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ vars.DOCKER_USERNAME }}
|
username: ${{ vars.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
@@ -25,8 +25,27 @@ jobs:
|
|||||||
docker build . --file Dockerfile --tag $IMAGE_TAG
|
docker build . --file Dockerfile --tag $IMAGE_TAG
|
||||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||||
# Uncomment below to push the image to Docker Hub (or another registry)
|
# Uncomment below to push the image to Docker Hub (or another registry)
|
||||||
|
- name: Scan image for vulnerabilities
|
||||||
|
uses: aquasecurity/trivy-action@0.35.0
|
||||||
|
with:
|
||||||
|
image-ref: ${{ env.IMAGE_TAG }}
|
||||||
|
format: table
|
||||||
|
exit-code: '1'
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
- name: Push the Docker image
|
- name: Push the Docker image
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
|
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
|
||||||
docker push snachodog/kiosk-guestbook:latest
|
docker push snachodog/kiosk-guestbook:latest
|
||||||
|
- name: Notify via ntfy
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
env:
|
||||||
|
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||||
|
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
curl -s -o /dev/null \
|
||||||
|
-H "Title: kiosk-guestbook image pushed to Docker Hub" \
|
||||||
|
-H "Tags: white_check_mark" \
|
||||||
|
-H "Authorization: Bearer $NTFY_TOKEN" \
|
||||||
|
-d "The kiosk-guestbook container has been pushed to Docker Hub and is ready to pull. Commit: ${{ github.sha }} — ${{ github.event.head_commit.message }}" \
|
||||||
|
"$NTFY_URL"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
todo:
|
todo:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: alstr/todo-to-issue-action@v5
|
- uses: alstr/todo-to-issue-action@v5
|
||||||
with:
|
with:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
# Use a lightweight Python image
|
# Use a lightweight Python image
|
||||||
FROM python:3.9-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies (including gettext for envsubst)
|
# Install system dependencies (including gettext for envsubst and gosu for privilege dropping)
|
||||||
RUN apt-get update && apt-get install -y gettext && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gettext gosu && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -30,7 +30,7 @@ ARG UID=1000
|
|||||||
ARG GID=1000
|
ARG GID=1000
|
||||||
RUN groupadd -g ${GID} appuser && useradd -u ${UID} -g ${GID} -s /bin/sh -M appuser
|
RUN groupadd -g ${GID} appuser && useradd -u ${UID} -g ${GID} -s /bin/sh -M appuser
|
||||||
RUN chown -R appuser:appuser /app /entrypoint.sh
|
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
|
# Use the entrypoint script as the container's command
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -96,13 +96,64 @@ Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (o
|
|||||||
|
|
||||||
`docker-compose logs -f`
|
`docker-compose logs -f`
|
||||||
|
|
||||||
|
## Admin Interface
|
||||||
|
|
||||||
|
A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted. 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`, `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
|
## API Access
|
||||||
|
|
||||||
Access the API endpoint to export guest entries by navigating to:
|
Access the API endpoint to export guest entries by navigating to:
|
||||||
|
|
||||||
`http://your-server-ip:8000/guests/api`
|
`http://your-server-ip:8000/api/guests`
|
||||||
|
|
||||||
This endpoint can be integrated with on-prem automation tools like n8n.
|
Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-Key` header. This endpoint can be integrated with on-prem automation tools like n8n.
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables.
|
||||||
|
|
||||||
|
As of **v2.1.0**, the following variables are required for the admin interface:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Additional Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
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 email_validator import validate_email, EmailNotValidError
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
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
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -13,7 +22,85 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
||||||
|
|
||||||
|
_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=[])
|
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():
|
def load_banned_words():
|
||||||
banned_words = set()
|
banned_words = set()
|
||||||
@@ -36,6 +123,14 @@ def load_banned_words():
|
|||||||
|
|
||||||
BANNED_WORDS = 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):
|
def contains_banned_words(text):
|
||||||
lower = text.lower()
|
lower = text.lower()
|
||||||
# Whole-word check (punctuation-stripped) — catches exact matches
|
# Whole-word check (punctuation-stripped) — catches exact matches
|
||||||
@@ -51,6 +146,10 @@ def contains_banned_words(text):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database migrations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Each entry is a list of SQL statements for that schema version.
|
# 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.
|
# To add a column or index in the future, append a new list — never modify existing entries.
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
@@ -69,6 +168,15 @@ MIGRATIONS = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)',
|
'CREATE INDEX IF NOT EXISTS idx_guests_id ON guests (id DESC)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_guests_email ON guests (email)',
|
'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():
|
def migrate_db():
|
||||||
@@ -110,6 +218,26 @@ def is_valid_email(email):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
migrate_db()
|
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'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@limiter.limit("5 per minute", methods=["POST"])
|
@limiter.limit("5 per minute", methods=["POST"])
|
||||||
def index():
|
def index():
|
||||||
@@ -126,6 +254,14 @@ def index():
|
|||||||
if not (first_name and last_name and location):
|
if not (first_name and last_name and location):
|
||||||
error = "First name, last name, and location are required."
|
error = "First name, last name, and location are required."
|
||||||
logger.warning("Missing required fields.")
|
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):
|
elif email and not is_valid_email(email):
|
||||||
error = "Invalid email address."
|
error = "Invalid email address."
|
||||||
logger.warning("Invalid email: %s", email)
|
logger.warning("Invalid email: %s", email)
|
||||||
@@ -163,6 +299,13 @@ def index():
|
|||||||
error="Unable to save your entry. Please try again.",
|
error="Unable to save your entry. Please try again.",
|
||||||
guests=[])
|
guests=[])
|
||||||
logger.info("Added guest: %s %s from %s", first_name, last_name, location)
|
logger.info("Added guest: %s %s from %s", first_name, last_name, location)
|
||||||
|
threading.Thread(target=_fire_webhook, args=({
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"email": email,
|
||||||
|
"location": location,
|
||||||
|
"newsletter_opt_in": newsletter_opt_in,
|
||||||
|
},), daemon=True).start()
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -177,7 +320,172 @@ def index():
|
|||||||
logger.info("Rendering index with %d guests.", len(guests))
|
logger.info("Rendering index with %d guests.", len(guests))
|
||||||
return render_template('index.html', error=error, guests=guests)
|
return render_template('index.html', error=error, guests=guests)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
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)
|
||||||
|
|
||||||
|
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
|
||||||
|
@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()
|
||||||
|
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')
|
||||||
|
@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'])
|
@app.route('/api/guests', methods=['GET'])
|
||||||
|
@limiter.limit("100 per hour")
|
||||||
|
@csrf.exempt
|
||||||
def api_guests():
|
def api_guests():
|
||||||
api_key = request.headers.get('X-API-Key')
|
api_key = request.headers.get('X-API-Key')
|
||||||
if api_key != os.environ.get("API_KEY"):
|
if api_key != os.environ.get("API_KEY"):
|
||||||
|
|||||||
+14
-3
@@ -1,7 +1,18 @@
|
|||||||
#!/bin/sh
|
#!/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.
|
||||||
|
DATA_DIR=$(dirname "${DATABASE_PATH:-/data/guestbook.db}")
|
||||||
|
chown -R appuser:appuser "$DATA_DIR"
|
||||||
|
|
||||||
# Process index.html.template to create index.html
|
# Process index.html.template to create index.html
|
||||||
# Adjust the path if your template is located somewhere else
|
|
||||||
envsubst < /app/templates/index.html.template > /app/templates/index.html
|
envsubst < /app/templates/index.html.template > /app/templates/index.html
|
||||||
|
|
||||||
# Start Gunicorn; using an environment variable for workers (default is 3)
|
# Drop to appuser and start Gunicorn
|
||||||
exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${GUNICORN_WORKERS:-3}
|
exec gosu appuser gunicorn \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers ${GUNICORN_WORKERS:-3} \
|
||||||
|
--timeout 30 \
|
||||||
|
app:app
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ PID=1000
|
|||||||
GID=1000
|
GID=1000
|
||||||
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
||||||
LOGO_URL="/static/images/logo.png"
|
LOGO_URL="/static/images/logo.png"
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
SECRET_KEY=change-this-to-a-random-secret-key
|
||||||
|
# Optional: POST new signups as JSON to this URL (e.g. an n8n Webhook node)
|
||||||
|
WEBHOOK_URL=
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
Flask>=3.1.3
|
Flask>=3.1.3
|
||||||
|
Flask-WTF>=1.2
|
||||||
Werkzeug>=3.0.6
|
Werkzeug>=3.0.6
|
||||||
Flask-Limiter>=3.0
|
Flask-Limiter>=3.0
|
||||||
|
Flask-Login>=0.6
|
||||||
email-validator>=2.0
|
email-validator>=2.0
|
||||||
gunicorn
|
gunicorn
|
||||||
|
tzdata
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<!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">{{ current_user.username }} · {{ total }} entries</span>
|
||||||
|
{% 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] | 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>
|
||||||
|
|
||||||
|
{% 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,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Guestbook Admin — Login</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-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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<!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>
|
||||||
|
<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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,7 +8,18 @@
|
|||||||
|
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
<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>
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Vollkorn', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrolling marquee styles */
|
/* Scrolling marquee styles */
|
||||||
.scrolling-wrapper {
|
.scrolling-wrapper {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -59,19 +70,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/" class="mb-4">
|
<form method="post" action="/" class="mb-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="first_name" class="form-label">First Name(s):</label>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="last_name" class="form-label">Last Name:</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Email + Newsletter Block (fully fixed) -->
|
<!-- Email + Newsletter Block (fully fixed) -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label">Email (Optional):</label>
|
<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">
|
<div class="form-check mt-2">
|
||||||
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
|
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
|
||||||
@@ -84,13 +96,13 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="location" class="form-label">Location:</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Comment field hidden by default -->
|
<!-- Comment field hidden by default -->
|
||||||
<div class="mb-3" id="comment-field" style="display: none;">
|
<div class="mb-3" id="comment-field" style="display: none;">
|
||||||
<label for="comment" class="form-label">Comment (Optional):</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user