63 Commits

Author SHA1 Message Date
dependabot[bot] 93620027c1 chore(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 10:06:50 +00:00
steve 613a9dc590 ci: ignore CVE-2026-4878 and CVE-2026-33845 (no fix available)
Both CVEs affect Debian 13 base image packages with no fixed version:
- CVE-2026-4878: libcap2 privilege escalation via TOCTOU race in cap_set_file()
- CVE-2026-33845: libgnutls30t64 DoS via DTLS zero-length fragment
2026-05-03 16:21:24 -06:00
steve c371b9a04f ci: suppress 3 unfixed Debian CVEs via .trivyignore
CVE-2025-69720 (ncurses), CVE-2026-27135 (nghttp2), and CVE-2026-29111
(systemd) have no upstream fix available. .trivyignore suppresses them
so Trivy can still gate on all other CRITICAL/HIGH findings without
relying on the coarser ignore-unfixed flag in the workflow.
2026-05-03 09:35:38 -06:00
steve d57ba928c4 chore(deps): bump Werkzeug to >=3.1.8 and Flask-Login to >=0.6.3
Resolves dependabot PRs #30 and #31 which had merge conflicts after
the Flask-Limiter v4 and email-validator bumps landed. Flask-Login
0.6.3 adds Flask 3 / Werkzeug 3 compatibility.
2026-04-27 08:53:02 -06:00
steve c1db6ee692 Merge pull request #29 from tmdinosaurcenter/dependabot/pip/flask-limiter-gte-4.1.1
chore(deps): update flask-limiter requirement from >=3.0 to >=4.1.1
2026-04-27 08:45:17 -06:00
steve 523a9e22c2 Merge pull request #28 from tmdinosaurcenter/dependabot/pip/email-validator-gte-2.3.0
chore(deps): update email-validator requirement from >=2.0 to >=2.3.0
2026-04-27 08:45:09 -06:00
steve f5af8b556f Merge pull request #27 from tmdinosaurcenter/dependabot/pip/flask-wtf-gte-1.3.0
chore(deps): update flask-wtf requirement from >=1.2 to >=1.3.0
2026-04-27 08:45:05 -06:00
steve 211c94b8c8 Merge pull request #26 from tmdinosaurcenter/dependabot/github_actions/aquasecurity/trivy-action-0.36.0
chore(deps): bump aquasecurity/trivy-action from 0.35.0 to 0.36.0
2026-04-27 08:45:01 -06:00
dependabot[bot] a7350bc3d5 chore(deps): update flask-limiter requirement from >=3.0 to >=4.1.1
Updates the requirements on [flask-limiter](https://github.com/alisaifee/flask-limiter) to permit the latest version.
- [Release notes](https://github.com/alisaifee/flask-limiter/releases)
- [Changelog](https://github.com/alisaifee/flask-limiter/blob/master/HISTORY.rst)
- [Commits](https://github.com/alisaifee/flask-limiter/compare/3.0.0...4.1.1)

---
updated-dependencies:
- dependency-name: flask-limiter
  dependency-version: 4.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 11:57:46 +00:00
dependabot[bot] e3ed22a201 chore(deps): update email-validator requirement from >=2.0 to >=2.3.0
Updates the requirements on [email-validator](https://github.com/JoshData/python-email-validator) to permit the latest version.
- [Release notes](https://github.com/JoshData/python-email-validator/releases)
- [Changelog](https://github.com/JoshData/python-email-validator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/JoshData/python-email-validator/compare/v2.0.0...v2.3.0)

---
updated-dependencies:
- dependency-name: email-validator
  dependency-version: 2.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 11:57:43 +00:00
dependabot[bot] d37887fb93 chore(deps): update flask-wtf requirement from >=1.2 to >=1.3.0
Updates the requirements on [flask-wtf](https://github.com/pallets-eco/flask-wtf) to permit the latest version.
- [Release notes](https://github.com/pallets-eco/flask-wtf/releases)
- [Changelog](https://github.com/pallets-eco/flask-wtf/blob/main/docs/changes.rst)
- [Commits](https://github.com/pallets-eco/flask-wtf/compare/v1.2.0...v1.3.0)

---
updated-dependencies:
- dependency-name: flask-wtf
  dependency-version: 1.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 11:57:40 +00:00
dependabot[bot] 519911c8f5 chore(deps): bump aquasecurity/trivy-action from 0.35.0 to 0.36.0
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.35.0 to 0.36.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 11:57:26 +00:00
steve b20e118def feat: add offline queue for kiosk form submissions
Intercepts form submit via fetch and stores failed submissions in
IndexedDB when offline. Replays queued entries on the online event and
on each page load. Shows an offline banner on the form page and a
sync-pending message on the thank-you page.

Service worker bumped to guestbook-v2 to pre-cache offline-queue.js
so the script is available when the kiosk has no network.
2026-03-29 20:22:25 -06:00
steve 6577a733c6 feat: add /api/csrf endpoint for offline queue token refresh
Returns the current session CSRF token as JSON so the offline queue
replay can obtain a fresh token without parsing HTML. Rate-limited to
30 requests/minute. Passes offline flag through to the thank-you
template.
2026-03-29 20:22:07 -06:00
steve 7914ac1ed7 feat: add summary stats bar to admin page
Displays total entries, this week, this month, and newsletter opt-in
count and percentage at the top of the admin view. Week/month
boundaries are computed in America/Denver time and converted to UTC
for SQLite comparison, handling DST correctly.
2026-03-29 19:48:24 -06:00
steve d1d2065da2 feat: add thank-you confirmation screen after form submission
Redirects to /thank-you?name=<first_name> on successful submission
instead of back to the blank form. Shows a 4-second countdown with
meta-refresh fallback before returning to the form. Includes the
scrolling guest marquee so the page feels consistent with the kiosk.
2026-03-29 19:48:15 -06:00
steve 047f57513d feat: add PWA support and mobile admin card layout
All pages: manifest link, apple-mobile-web-app meta tags, theme-color,
viewport-fit=cover, overscroll-behavior:none, safe-area padding, 16px
input font-size to prevent iOS zoom, SW registration.

admin.html: card-per-entry layout on small screens (d-md-none) with
name, location, timestamp, newsletter status, email, comment, and
delete button. Desktop table unchanged (d-none d-md-block).
2026-03-29 19:20:29 -06:00
steve 3057201102 feat: add PWA manifest and service worker routes
Serves /manifest.webmanifest dynamically from Flask so SITE_TITLE and
LOGO_URL env vars flow into the manifest at runtime. Serves /sw.js from
/static/sw.js with Service-Worker-Allowed: / header to allow root scope.
Service worker caches static assets and passes all app routes to network.
2026-03-29 19:20:06 -06:00
steve 12bc0cd4a1 fix(ci): update trivy-action to v0.35.0
Version 0.30.0 does not exist; latest release is v0.35.0.
2026-03-29 07:30:35 -06:00
steve bc8d4f9fe5 fix(github) bumped trivy to latest version 2026-03-28 23:29:00 -06:00
steve aa7fefe497 ci: scan Docker image for CRITICAL/HIGH CVEs with Trivy
Runs aquasecurity/trivy-action after the build step and fails the
workflow if any CRITICAL or HIGH severity vulnerabilities are found,
blocking the push to Docker Hub.
2026-03-28 23:23:54 -06:00
steve 898441af0c fix: add set -e and gunicorn worker timeout to entrypoint
set -e ensures the script aborts on any error (e.g. failed chown)
rather than silently continuing. --timeout 30 kills hung workers to
prevent slow-client attacks from exhausting the worker pool.
2026-03-28 23:23:53 -06:00
steve 617aa5f028 fix: enforce max input lengths on guestbook form
Adds FIELD_MAX constants and server-side length checks in the index
route. Adds matching maxlength attributes on all form inputs so the
browser enforces limits before submission.
2026-03-28 23:23:53 -06:00
steve ecdcc044b7 feat: add CSRF protection to all POST forms
Installs Flask-WTF and enables CSRFProtect globally. Adds csrf_token
hidden fields to all four POST forms (login, delete entry, add user,
delete user, and the public guestbook form). Exempts the API endpoint
which uses header-based key auth instead.
2026-03-28 23:23:53 -06:00
steve 9ad7128619 feat: add security headers, session hardening, and admin cache control
Sets X-Content-Type-Options, X-Frame-Options, and Referrer-Policy on
all responses. Prevents browsers from caching admin pages. Configures
session cookies as HttpOnly and SameSite=Lax with an 8-hour lifetime.
2026-03-28 23:23:53 -06:00
steve 61a298a735 fix: rate-limit admin login and API endpoint
Limits POST to /admin/login to 10 requests/minute to block brute-force
attacks. Limits GET /api/guests to 100 requests/hour to prevent bulk
data exfiltration.
2026-03-28 23:23:52 -06:00
steve 4d58e0f0a1 fix: abort startup if SECRET_KEY is not set
Raises RuntimeError at startup instead of silently falling back to a
hardcoded default, preventing misconfigured deployments from running
with a publicly-known session key.
2026-03-28 23:23:52 -06:00
steve 53741a4cbf Merge pull request #25 from tmdinosaurcenter/dependabot/docker/python-3.14-slim
Bump python from 3.9-slim to 3.14-slim
2026-03-28 23:09:34 -06:00
steve 4c691ab31a Merge pull request #24 from tmdinosaurcenter/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 2 to 4
2026-03-28 23:09:32 -06:00
steve 77c377ab51 Merge pull request #23 from tmdinosaurcenter/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-03-28 23:09:24 -06:00
steve ae5002d407 Merge pull request #22 from tmdinosaurcenter/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 2 to 4
2026-03-28 23:09:21 -06:00
dependabot[bot] 5f71641cf0 Bump python from 3.9-slim to 3.14-slim
Bumps python from 3.9-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:05:28 +00:00
dependabot[bot] c1206a244c Bump docker/setup-buildx-action from 2 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:04:59 +00:00
dependabot[bot] 8230ae1c1c Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:04:55 +00:00
dependabot[bot] c55037b37b Bump docker/login-action from 2 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 05:04:51 +00:00
steve 36f8a01999 ci: add Dependabot config and issue templates
Add weekly Dependabot updates for pip, Docker, and GitHub Actions.
Add issue templates for bug reports, feature requests, documentation,
and general feedback.
2026-03-28 23:04:13 -06:00
steve 4f675fe74c feat: display admin timestamps in America/Denver time
Convert UTC timestamps from SQLite to Mountain Time (America/Denver)
using a Jinja2 template filter backed by zoneinfo; add tzdata dependency
for IANA timezone data in the slim Docker image.
2026-03-28 22:58:37 -06:00
steve d5eac47ceb feat: apply TMDC brand fonts to guestbook page
Use Vollkorn 700 for headings and Open Sans for body text,
in line with The Montana Dinosaur Center style guide.
2026-03-11 18:05:08 -06:00
steve 9ebac80f35 feat: add webhook integration for new guestbook submissions
Posts signup data as JSON to WEBHOOK_URL (e.g. an n8n Webhook node)
in a daemon thread so it never blocks the visitor-facing response.
2026-03-11 15:30:31 -06:00
steve 2d4eac6583 refactor: migrate admin auth from HTTP Basic to Flask-Login sessions
Replaces browser-cached Basic Auth credentials with proper server-side
session management. Logout now fully invalidates the session. Adds an
HTML login form at /admin/login, SECRET_KEY env var support, and updates
README with key generation instructions and role table.
2026-03-10 11:41:16 -06:00
steve 94d6690e57 fix: add logout button to admin pages 2026-03-10 10:39:10 -06:00
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
steve 78ef3eeb85 refactor: replace init_db with lightweight schema migration system
- Add MIGRATIONS list — each entry is a list of SQL statements for
  that schema version; append new lists to add future migrations,
  never modify existing ones
- Add schema_version table to track applied migrations
- migrate_db() runs on startup and applies any pending versions
  automatically; safe to run against existing DBs (v1 uses
  CREATE IF NOT EXISTS so it no-ops on the existing table/indexes)
2026-03-09 21:01:35 -06:00
steve 46dca45e04 fix: correct WORKERS var, export path, and seamless marquee loop
- entrypoint.sh: use GUNICORN_WORKERS to match example.env (#17)
- guestbook_export.py: read DATABASE_PATH from env instead of
  hardcoded relative path (#18)
- Scrolling marquee: duplicate guest list for seamless loop,
  animate translateX(0) to translateX(-50%), increase font to
  1.25rem, fix JS speed calc to use half content width (#20)
2026-03-09 20:52:00 -06:00
steve 2dc276f098 fix: improve profanity filter to catch spacing and embedding bypasses
Add a secondary normalized substring check: strips all non-alpha chars
then checks if any banned word appears as a substring. This catches:
- Spacing tricks: 'f u c k'
- Embedded forms: 'fucking'
Note: substring matching can produce false positives (e.g. 'classic'
contains 'ass'). Trade-off accepted for a museum kiosk context.
2026-03-09 20:48:26 -06:00
steve e6d742f92e fix: replace regex email validation with email-validator
Swap hand-rolled regex for the email-validator library which handles
RFC 5322 edge cases correctly. check_deliverability=False skips DNS
lookups (not viable on an intranet). Blank email still passes — only
a non-empty, malformed address triggers the error.
2026-03-09 20:36:54 -06:00
steve e0d72f8057 feat: add rate limiting to form submission
Add Flask-Limiter and cap POST submissions to 5 per minute per IP.
GET requests are not limited. Uses in-memory storage (appropriate
for single-instance kiosk deployment).
2026-03-09 20:29:17 -06:00
steve d98dd1518b Remove CSRF TODO — closed as won't fix in #11 2026-03-09 20:26:42 -06:00
steve 920463b4a7 fix: add database error handling throughout app
Wrap all sqlite3 operations in try/except sqlite3.Error:
- SELECT on validation error path: falls back to empty guest list
- INSERT on form submit: shows user-friendly retry message
- SELECT on page load: falls back to empty guest list
- SELECT in /api/guests: returns 503 JSON response
2026-03-09 20:24:09 -06:00
steve a178e6193b Keep PII logging as intentional — close #8
Logging guest name and location is appropriate here: visitors knowingly
submit this info for a newsletter, and the log is useful for confirming
submissions and debugging on an intranet-only kiosk.
2026-03-09 20:19:28 -06:00
steve 0c4d3ab15d perf: add DB indexes and cap guest queries at 100 rows
- Add idx_guests_id and idx_guests_email indexes in init_db()
- Cap all SELECT queries on the guests table to LIMIT 100 to prevent
  unbounded memory growth as the guestbook accumulates entries
2026-03-09 20:17:34 -06:00
steve 3e17574fe6 fix: upgrade to Flask 3.x and replace before_first_request
- Pin Flask to >=3.1.3 to resolve all outstanding Dependabot CVEs
  (session cookie Vary header, Werkzeug DoS/RCE/safe_join vulns)
- Replace removed @before_first_request decorator with app.app_context()
  call at module level, compatible with Flask 3.0+
2026-03-09 20:15:14 -06:00
steve 0c8491ce7a feat: run container as non-root user
Create appuser with configurable UID/GID (default 1000, matching
example.env PID/GID vars) and switch to it before starting Gunicorn.
Override at build time with --build-arg UID=... --build-arg GID=...

Note: the /data volume mount must be owned by the matching UID on the
host for the DB to remain writable.
2026-03-09 20:13:21 -06:00
steve 1a0a1371bc fix: correct marquee scroll speed and add code TODOs
- Fixed scrolling marquee to use a fixed px/s speed via JS instead of
  a fixed duration, preventing it from speeding up as entries are added
- Added inline TODO comments throughout codebase to track known issues
  (rate limiting, CSRF, unbounded queries, deprecated Flask decorator,
  PII logging, schema versioning, Docker non-root user, etc.)
- Added todo-to-issue GitHub Action to auto-create Issues from TODOs on push to main
- Added .claude/ to .gitignore
2026-03-09 19:30:13 -06:00
steve d260bc6f9f docs: remove outdated project structure section from README 2025-04-04 18:48:20 -06:00
steve 412d373421 docs: add Portainer setup instructions to README 2025-04-04 16:28:34 -06:00
24 changed files with 1703 additions and 116 deletions
+35
View File
@@ -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.
+22
View File
@@ -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"
+23
View File
@@ -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.
+23
View File
@@ -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.
+15
View File
@@ -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.
+33
View File
@@ -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"
+22 -3
View File
@@ -9,12 +9,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- 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,8 +25,27 @@ 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: |
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"
+14
View File
@@ -0,0 +1,14 @@
name: TODO to Issue
on:
push:
branches: [ "main" ]
jobs:
todo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: alstr/todo-to-issue-action@v5
with:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -184,5 +184,8 @@ cython_debug/
# VS Code
.vscode/
# Claude Code
.claude/
.env
docker-compose.yml
+17
View File
@@ -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
+11 -3
View File
@@ -1,11 +1,11 @@
# Use a lightweight Python image
FROM python:3.9-slim
FROM python:3.14-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 .
@@ -24,5 +24,13 @@ ENV FLASK_ENV=production
# Expose the port (Gunicorn will run on 8000)
EXPOSE 8000
# Create a non-root user. UID/GID match the PID/GID vars in example.env (default 1000).
# Override at build time with: docker build --build-arg UID=1001 --build-arg GID=1001
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
# 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"]
+83 -38
View File
@@ -20,28 +20,6 @@ Uses Docker and Docker Compose for a production-ready environment with Gunicorn
- Configurable Template:
The applications title and logo can be dynamically configured via environment variables without rebuilding the image.
### Project Structure
```
kiosk-guestbook/
├── app.py # Main Flask application code
├── Dockerfile # Dockerfile for building the image
├── docker-compose.yml # Docker Compose configuration (see deployment instructions below)
├── example.docker-compose.yml # Example Docker Compose file for deployment
├── example.env # Example environment variable file
├── entrypoint.sh # Entrypoint script that processes templates and starts Gunicorn
├── en.txt # Profanity list file (one banned word per line)
├── README.md # Project documentation
├── requirements.txt # Python dependencies (Flask, Gunicorn, etc.)
├── scripts/
│ └── guestbook_export.py # Script to export guest entries to CSV (e.g., for Mailchimp)
├── static/
│ └── images/
│ └── logo.png # Default logo for display in the application (configurable via env variable)
└── templates/
└── index.html.template # HTML template for the guestbook page (processed to index.html at runtime)
```
## Getting Started
### Prerequisites
@@ -54,8 +32,8 @@ kiosk-guestbook/
Before proceeding, youll need to have the example configuration files. These files—example.docker-compose.yml and example.env—are included in the repository. You can download them by cloning the entire repository:
```
git clone <https://github.com/tmdinosaurcenter/kiosk-guestbook.git>
```bash
git clone https://github.com/tmdinosaurcenter/kiosk-guestbook.git
cd kiosk-guestbook
```
@@ -66,12 +44,12 @@ If you dont wish to clone the entire repo, you can also download the two file
1. Copy Example Files:
From the project root, copy the example files:
```
``` bash
cp example.docker-compose.yml docker-compose.yml
cp example.env .env
```
2. **Edit the `.env` File (Optional):**
2. **Edit the `.env` File (Optional)**
Modify `.env` to customize settings such as `SITE_TITLE`, `LOGO_URL`, `PORT`, etc.
@@ -83,16 +61,32 @@ Run the following command to pull (or use) the pre-built image and start the con
This command starts the container in detached mode, mounts the persistent volume for the SQLite database, and uses your environment variable settings.
4. **Access the Application:**
4. **Access the Application**
Open your browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your `.env` file).
### Method 2: Running in Portainer
1.
2.
3.
4.
1. **Copy Example Files**
```bash
cp example.docker-compose.yml docker-compose.yml
cp example.env stack.env
```
*Note*: Portainer expects the environment file to be named stack.env rather than .env
2. **Edit `docker-compose.yml`**
In the `docker-compose.yml` file, update the environment file reference from `.env` to `stack.env`
3. **Deploy via Portainer**
- Log in to Portainer and navigate to the "Stacks" section.
- Create a new stack and upload or paste your modified docker-compose.yml along with the `stack.env` file.
- Deploy the stack. Portainer will use stack.env for the environment variables.
4. **Access the Application:**
Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (or your specified port) to view the application.
## Logging and Monitoring
@@ -102,25 +96,76 @@ Open your browser and navigate to `http://<your-server-ip>:8000` (or the port sp
`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
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
- **Intranet-Only Deployment:**
- **Intranet-Only Deployment**
This application is designed for internal use only and is not exposed to the public internet.
- **Database Persistence:**
- **Database Persistence**
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
- **Production Considerations:**
- **Production Considerations**
The app runs with Gunicorn as a production-ready WSGI server. Adjust worker counts and resource limits as needed based on your servers specifications.
## License:
This project is licensed under the [MIT License](LICENSE).
## License
This project is licensed under the [MIT License](LICENSE).
+531 -24
View File
@@ -1,8 +1,20 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
import sqlite3
import re
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)
@@ -11,6 +23,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()
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
@@ -32,19 +123,39 @@ 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):
words = text.lower().split()
for word in words:
word_clean = word.strip(".,!?;:\"'")
if word_clean in BANNED_WORDS:
lower = text.lower()
# Whole-word check (punctuation-stripped) — catches exact matches
for word in lower.split():
if word.strip(".,!?;:\"'") in BANNED_WORDS:
return True
# Normalized substring check — catches spacing tricks (f u c k) and
# embedded forms (fucking). Note: may produce false positives on words
# that contain a banned word as a substring (e.g. "classic" → "ass").
normalized = re.sub(r'[^a-z]', '', lower)
for banned in BANNED_WORDS:
if banned in normalized:
return True
return False
def init_db():
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS guests (
# ---------------------------------------------------------------------------
# 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 = [
# v1 — initial schema
[
'''CREATE TABLE IF NOT EXISTS guests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
@@ -53,21 +164,82 @@ def init_db():
comment TEXT,
newsletter_opt_in BOOLEAN DEFAULT 1,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
)''',
'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():
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
# Bootstrap the version table and seed it at 0 if empty
c.execute('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)')
if c.execute('SELECT COUNT(*) FROM schema_version').fetchone()[0] == 0:
c.execute('INSERT INTO schema_version VALUES (0)')
conn.commit()
current = c.execute('SELECT version FROM schema_version').fetchone()[0]
pending = MIGRATIONS[current:]
if not pending:
logger.info("Database schema is up to date at v%d.", current)
conn.close()
return
for statements in pending:
current += 1
logger.info("Applying migration v%d...", current)
for sql in statements:
c.execute(sql)
c.execute('UPDATE schema_version SET version = ?', (current,))
conn.commit()
logger.info("Database migrated to v%d.", current)
conn.close()
logger.info("Database initialized.")
def is_valid_email(email):
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return re.match(pattern, email)
try:
validate_email(email, check_deliverability=False)
return True
except EmailNotValidError:
return False
@app.before_first_request
def initialize_database():
init_db()
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():
error = None
if request.method == 'POST':
@@ -82,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)
@@ -90,13 +270,18 @@ def index():
logger.warning("Profanity detected in comment.")
if error:
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
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: %s", e)
guests = []
return render_template('index.html', error=error, guests=guests)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute(
@@ -108,23 +293,298 @@ def index():
)
conn.commit()
conn.close()
except sqlite3.Error as e:
logger.error("Database error saving guest: %s", e)
return render_template('index.html',
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)
c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
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: %s", e)
guests = []
logger.info("Rendering index with %d guests.", len(guests))
return render_template('index.html', error=error, guests=guests)
# ---------------------------------------------------------------------------
# Thank-you 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()
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 ?
''', (per_page, offset))
guests = c.fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error in admin: %s", e)
guests = []
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, stats=stats)
@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/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"):
abort(403)
try:
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('''
@@ -135,6 +595,9 @@ def api_guests():
''')
rows = c.fetchall()
conn.close()
except sqlite3.Error as e:
logger.error("Database error in api_guests: %s", e)
return jsonify({"error": "Database unavailable"}), 503
guests = [
{
@@ -150,7 +613,51 @@ 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__':
init_db()
migrate_db()
logger.info("Starting development server at http://0.0.0.0:8000")
app.run(host='0.0.0.0', port=8000)
+14 -3
View File
@@ -1,7 +1,18 @@
#!/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
# 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 ${WORKERS:-3}
# Drop to appuser and start Gunicorn
exec gosu appuser gunicorn \
--bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--timeout 30 \
app:app
+5
View File
@@ -10,3 +10,8 @@ PID=1000
GID=1000
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=
+7 -2
View File
@@ -1,3 +1,8 @@
Flask==2.2.5
Werkzeug>=3.0.6
Flask>=3.1.3
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
+2 -2
View File
@@ -1,8 +1,8 @@
import csv
import os
import sqlite3
# Update the database file path if needed.
DATABASE = 'guestbook.db'
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
EXPORT_FILE = 'mailchimp_export.csv'
def export_guestbook_to_csv():
+223
View File
@@ -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();
}
});
+50
View File
@@ -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
});
+150
View File
@@ -0,0 +1,150 @@
<!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</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-3">
<h1 class="h3 mb-0">Guestbook Admin</h1>
<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="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] }} &middot; {{ 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 %}
<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>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', { scope: '/' });
});
}
</script>
</body>
</html>
+56
View File
@@ -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>
+101
View File
@@ -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>
+87 -10
View File
@@ -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,21 +54,23 @@
width: 100%;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
padding-bottom: env(safe-area-inset-bottom);
}
.scrolling-content {
display: inline-block;
padding: 10px;
animation: scroll-left 20s linear infinite;
font-size: 1.25rem;
animation: scroll-left linear infinite;
}
@keyframes scroll-left {
0% {
transform: translateX(100%);
transform: translateX(0);
}
100% {
transform: translateX(-100%);
transform: translateX(-50%);
}
}
</style>
@@ -51,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 &mdash; your entry will be saved and submitted automatically when reconnected.
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
@@ -58,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"
@@ -83,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>
@@ -97,16 +140,39 @@
</div>
<!-- Scrolling Guest Entries at the Bottom -->
<!-- Content is duplicated so the loop is seamless: animate 0 → -50% -->
<div class="scrolling-wrapper">
<div class="scrolling-content">
{% for guest in guests %}
<span class="me-4">
<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>
<!-- Set scrolling speed to a fixed pixels-per-second rate -->
<script>
(function () {
const pixelsPerSecond = 80;
const content = document.querySelector(".scrolling-content");
function updateScrollSpeed() {
// Travel distance is half the total width (one copy of the list)
const oneCopyWidth = content.offsetWidth / 2;
content.style.animationDuration = (oneCopyWidth / pixelsPerSecond) + "s";
}
updateScrollSpeed();
window.addEventListener("resize", updateScrollSpeed);
})();
</script>
<!-- JavaScript to reveal the comment field -->
<script>
document.addEventListener("DOMContentLoaded", function () {
@@ -136,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>
+145
View File
@@ -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&rsquo;re currently offline &mdash; 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>