mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-28 18:59:05 -06:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93620027c1 | |||
| 613a9dc590 | |||
| c371b9a04f | |||
| d57ba928c4 | |||
| c1db6ee692 | |||
| 523a9e22c2 | |||
| f5af8b556f | |||
| 211c94b8c8 | |||
| a7350bc3d5 | |||
| e3ed22a201 | |||
| d37887fb93 | |||
| 519911c8f5 | |||
| b20e118def | |||
| 6577a733c6 | |||
| 7914ac1ed7 | |||
| d1d2065da2 | |||
| 047f57513d | |||
| 3057201102 | |||
| 12bc0cd4a1 |
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to DockerHub
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
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@0.35.0
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.IMAGE_TAG }}
|
||||
format: table
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
todo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: alstr/todo-to-issue-action@v5
|
||||
with:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Unfixed OS-level vulnerabilities in Debian 13 (trixie) base image.
|
||||
# No fix available upstream as of 2026-04-27; revisit when patches land.
|
||||
|
||||
# ncurses: buffer overflow (libncursesw6, libtinfo6, ncurses-base, ncurses-bin)
|
||||
CVE-2025-69720
|
||||
|
||||
# nghttp2: DoS via malformed HTTP/2 frames after session termination (libnghttp2-14)
|
||||
CVE-2026-27135
|
||||
|
||||
# systemd: arbitrary code execution / DoS via spurious IPC (libsystemd0, libudev1)
|
||||
CVE-2026-29111
|
||||
|
||||
# libcap: privilege escalation via TOCTOU race in cap_set_file() (libcap2)
|
||||
CVE-2026-4878
|
||||
|
||||
# gnutls: DoS via DTLS zero-length fragment (libgnutls30t64)
|
||||
CVE-2026-33845
|
||||
@@ -13,7 +13,7 @@ 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 flask_wtf.csrf import CSRFProtect, generate_csrf
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
# Set up logging
|
||||
@@ -306,7 +306,7 @@ def index():
|
||||
"location": location,
|
||||
"newsletter_opt_in": newsletter_opt_in,
|
||||
},), daemon=True).start()
|
||||
return redirect(url_for('index'))
|
||||
return redirect(url_for('thank_you', name=first_name))
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
@@ -320,6 +320,31 @@ def index():
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -380,10 +405,30 @@ def admin():
|
||||
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()
|
||||
total = c.execute('SELECT COUNT(*) FROM guests').fetchone()[0]
|
||||
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 ?
|
||||
@@ -393,10 +438,18 @@ def admin():
|
||||
except sqlite3.Error as e:
|
||||
logger.error("Database error in admin: %s", e)
|
||||
guests = []
|
||||
total = 0
|
||||
total = this_week = this_month = newsletter_count = newsletter_pct = 0
|
||||
|
||||
stats = {
|
||||
'total': total,
|
||||
'week': this_week,
|
||||
'month': this_month,
|
||||
'newsletter_count': newsletter_count,
|
||||
'newsletter_pct': newsletter_pct,
|
||||
}
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages,
|
||||
total=total)
|
||||
total=total, stats=stats)
|
||||
|
||||
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
|
||||
@login_required
|
||||
@@ -416,6 +469,46 @@ def admin_delete(entry_id):
|
||||
logger.error("Database error deleting guest %d: %s", entry_id, e)
|
||||
return redirect(url_for('admin', page=request.args.get('page', 1)))
|
||||
|
||||
@app.route('/admin/export.csv')
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def admin_export_csv():
|
||||
if not _admin_configured():
|
||||
abort(503)
|
||||
if current_user.role == 'viewer':
|
||||
abort(403)
|
||||
import csv, io
|
||||
try:
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT id, first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
|
||||
FROM guests ORDER BY id ASC
|
||||
''')
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
logger.error("Database error in admin_export_csv: %s", e)
|
||||
abort(503)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(['id', 'first_name', 'last_name', 'email', 'location',
|
||||
'comment', 'newsletter_opt_in', 'timestamp_mountain'])
|
||||
for row in rows:
|
||||
ts = row[7]
|
||||
try:
|
||||
dt = datetime.strptime(str(ts), '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
||||
ts = dt.astimezone(_DISPLAY_TZ).strftime('%Y-%m-%d %H:%M')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
writer.writerow([row[0], row[1], row[2], row[3], row[4], row[5], int(row[6] or 0), ts])
|
||||
from flask import Response
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename="guestbook_export.csv"'}
|
||||
)
|
||||
|
||||
@app.route('/admin/users')
|
||||
@login_required
|
||||
def admin_users():
|
||||
@@ -520,6 +613,50 @@ def api_guests():
|
||||
]
|
||||
return jsonify(guests)
|
||||
|
||||
@app.route('/api/csrf', methods=['GET'])
|
||||
@csrf.exempt
|
||||
@limiter.limit("30 per minute")
|
||||
def api_csrf():
|
||||
return jsonify({"csrf_token": generate_csrf()})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/manifest.webmanifest')
|
||||
@csrf.exempt
|
||||
def pwa_manifest():
|
||||
import json as _json
|
||||
site_title = os.environ.get('SITE_TITLE', 'Guestbook')
|
||||
logo_url = os.environ.get('LOGO_URL', '/static/images/logo.png')
|
||||
manifest = {
|
||||
"name": site_title,
|
||||
"short_name": site_title,
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3a9cb8",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": logo_url,
|
||||
"sizes": "500x500",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
from flask import Response
|
||||
return Response(_json.dumps(manifest), mimetype='application/manifest+json')
|
||||
|
||||
@app.route('/sw.js')
|
||||
@csrf.exempt
|
||||
def service_worker():
|
||||
response = app.send_static_file('sw.js')
|
||||
response.headers['Service-Worker-Allowed'] = '/'
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrate_db()
|
||||
logger.info("Starting development server at http://0.0.0.0:8000")
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
Flask>=3.1.3
|
||||
Flask-WTF>=1.2
|
||||
Werkzeug>=3.0.6
|
||||
Flask-Limiter>=3.0
|
||||
Flask-Login>=0.6
|
||||
email-validator>=2.0
|
||||
Flask-WTF>=1.3.0
|
||||
Werkzeug>=3.1.8
|
||||
Flask-Limiter>=4.1.1
|
||||
Flask-Login>=0.6.3
|
||||
email-validator>=2.3.0
|
||||
gunicorn
|
||||
tzdata
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Offline queue for kiosk-guestbook.
|
||||
*
|
||||
* Intercepts the guestbook form submit via fetch. On network failure,
|
||||
* stores the submission in IndexedDB and shows an offline thank-you.
|
||||
* Replays queued entries on the `online` event and on each page load.
|
||||
*
|
||||
* Works on both the form page (/) and the thank-you page (/thank-you).
|
||||
* Background Sync is not used — it is unsupported in iOS Safari.
|
||||
*/
|
||||
|
||||
const OQ_DB_NAME = 'guestbook-offline-queue';
|
||||
const OQ_STORE = 'entries';
|
||||
const OQ_VERSION = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IndexedDB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function oqOpenDb() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var req = indexedDB.open(OQ_DB_NAME, OQ_VERSION);
|
||||
req.onupgradeneeded = function (e) {
|
||||
e.target.result.createObjectStore(OQ_STORE, { keyPath: 'id', autoIncrement: true });
|
||||
};
|
||||
req.onsuccess = function (e) { resolve(e.target.result); };
|
||||
req.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
}
|
||||
|
||||
function oqEnqueue(fields) {
|
||||
return oqOpenDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(OQ_STORE, 'readwrite');
|
||||
var store = tx.objectStore(OQ_STORE);
|
||||
store.add({ fields: fields, queued_at: new Date().toISOString() });
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function oqDequeue(id) {
|
||||
return oqOpenDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(OQ_STORE, 'readwrite');
|
||||
tx.objectStore(OQ_STORE).delete(id);
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function oqGetAll() {
|
||||
return oqOpenDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(OQ_STORE, 'readonly');
|
||||
var req = tx.objectStore(OQ_STORE).getAll();
|
||||
req.onsuccess = function (e) { resolve(e.target.result); };
|
||||
req.onerror = function (e) { reject(e.target.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collect form fields into a plain object (excludes csrf_token)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function oqCollectFields(form) {
|
||||
var fields = {};
|
||||
var fd = new FormData(form);
|
||||
fd.forEach(function (value, key) {
|
||||
if (key !== 'csrf_token') fields[key] = value;
|
||||
});
|
||||
// Explicitly record the newsletter checkbox so a missing key means opt-out
|
||||
var checkbox = form.querySelector('[name="newsletter_opt_in"]');
|
||||
if (checkbox && !checkbox.checked) {
|
||||
delete fields['newsletter_opt_in'];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue replay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var oqReplaying = false;
|
||||
|
||||
async function oqReplayQueue() {
|
||||
if (oqReplaying) return;
|
||||
oqReplaying = true;
|
||||
|
||||
var items;
|
||||
try {
|
||||
items = await oqGetAll();
|
||||
} catch (e) {
|
||||
oqReplaying = false;
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
oqReplaying = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch a fresh CSRF token once for the whole batch
|
||||
var token;
|
||||
try {
|
||||
var csrfRes = await fetch('/api/csrf');
|
||||
var csrfJson = await csrfRes.json();
|
||||
token = csrfJson.csrf_token;
|
||||
} catch (e) {
|
||||
// Still offline
|
||||
oqReplaying = false;
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('csrf_token', token);
|
||||
Object.keys(item.fields).forEach(function (k) {
|
||||
fd.append(k, item.fields[k]);
|
||||
});
|
||||
|
||||
var res = await fetch('/', { method: 'POST', body: fd });
|
||||
|
||||
if (res.ok) {
|
||||
await oqDequeue(item.id);
|
||||
} else if (res.status === 429) {
|
||||
// Rate-limited — leave remaining entries, try again later
|
||||
break;
|
||||
} else {
|
||||
// Server rejected (validation error etc.) — discard to unblock queue
|
||||
console.warn('oq: discarding entry', item.id, 'server returned', res.status);
|
||||
await oqDequeue(item.id);
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error again — stop, leave entries for next online event
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
oqReplaying = false;
|
||||
|
||||
// Update offline indicator if we're now fully synced
|
||||
var remaining;
|
||||
try { remaining = await oqGetAll(); } catch (e) { remaining = []; }
|
||||
if (!remaining.length) {
|
||||
oqSetIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline indicator (form page only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function oqSetIndicator(offline) {
|
||||
var el = document.getElementById('offline-indicator');
|
||||
if (!el) return;
|
||||
if (offline) {
|
||||
el.classList.remove('d-none');
|
||||
} else {
|
||||
el.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form submit intercept (form page only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function oqHandleSubmit(e) {
|
||||
e.preventDefault();
|
||||
var form = e.target;
|
||||
var fields = oqCollectFields(form);
|
||||
|
||||
try {
|
||||
var res = await fetch('/', { method: 'POST', body: new FormData(form) });
|
||||
// fetch follows redirects; res.url is the final URL (thank-you page on success)
|
||||
window.location.href = res.url;
|
||||
} catch (err) {
|
||||
// Network failure — queue and show offline thank-you
|
||||
try {
|
||||
await oqEnqueue(fields);
|
||||
} catch (dbErr) {
|
||||
console.error('oq: failed to enqueue', dbErr);
|
||||
}
|
||||
var name = fields['first_name'] || '';
|
||||
window.location.href = '/thank-you?name=' + encodeURIComponent(name) + '&offline=1';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap on DOMContentLoaded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Intercept form submit (form page only)
|
||||
var form = document.querySelector('form[action="/"]');
|
||||
if (form) {
|
||||
form.addEventListener('submit', oqHandleSubmit);
|
||||
}
|
||||
|
||||
// Sync indicator with current state
|
||||
if (!navigator.onLine) {
|
||||
oqSetIndicator(true);
|
||||
}
|
||||
|
||||
// Replay queue on reconnect
|
||||
window.addEventListener('online', function () {
|
||||
oqSetIndicator(false);
|
||||
oqReplayQueue();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', function () {
|
||||
oqSetIndicator(true);
|
||||
});
|
||||
|
||||
// Replay any previously queued items on page load
|
||||
if (navigator.onLine) {
|
||||
oqReplayQueue();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const CACHE_NAME = 'guestbook-v2';
|
||||
const STATIC_ASSETS = [
|
||||
'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css',
|
||||
'https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js',
|
||||
'https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap',
|
||||
'/static/images/logo.png',
|
||||
'/static/offline-queue.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.addAll(STATIC_ASSETS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Cache-first for CDN and local static assets
|
||||
const isStatic = STATIC_ASSETS.includes(event.request.url) ||
|
||||
(url.origin === self.location.origin && url.pathname.startsWith('/static/'));
|
||||
|
||||
if (isStatic) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(response => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-only for all app routes (/, /admin/*, /api/*, /manifest.webmanifest)
|
||||
// No caching of authenticated or dynamic content
|
||||
});
|
||||
+109
-43
@@ -2,16 +2,32 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Guestbook Admin</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { overscroll-behavior: none; }
|
||||
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
input, select, textarea { font-size: 16px !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">Guestbook Admin</h1>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted">{{ current_user.username }} · {{ total }} entries</span>
|
||||
<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 %}
|
||||
@@ -19,47 +35,89 @@
|
||||
</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>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
||||
<span class="badge bg-primary" style="font-size:0.9rem;">{{ stats.total }} total</span>
|
||||
<span class="badge bg-secondary">{{ stats.week }} this week</span>
|
||||
<span class="badge bg-secondary">{{ stats.month }} this month</span>
|
||||
<span class="badge bg-success">{{ stats.newsletter_count }} newsletter ({{ stats.newsletter_pct }}%)</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card list (phones) -->
|
||||
<div class="d-md-none">
|
||||
{% for g in guests %}
|
||||
<div class="card mb-2 shadow-sm">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{{ g[1] }} {{ g[2] }}</strong>
|
||||
<div class="text-muted small">{{ g[4] }} · {{ g[7] | localtime }}</div>
|
||||
<div class="small mt-1">Newsletter: {{ 'Yes' if g[6] else 'No' }}</div>
|
||||
{% if g[3] %}
|
||||
<div class="text-muted small">{{ g[3] }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No entries found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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 %}
|
||||
@@ -80,5 +138,13 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Guestbook Admin — 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;">
|
||||
@@ -31,5 +44,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Guestbook Admin — 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;">
|
||||
@@ -76,5 +89,13 @@
|
||||
Admins can view and delete entries. Viewers can only view.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
|
||||
<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 -->
|
||||
@@ -20,6 +28,23 @@
|
||||
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;
|
||||
@@ -29,6 +54,7 @@
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.scrolling-content {
|
||||
@@ -63,6 +89,10 @@
|
||||
Providing your email is optional, but it helps us follow up if needed.
|
||||
</div>
|
||||
|
||||
<div id="offline-indicator" class="alert alert-warning d-none" role="status">
|
||||
No internet connection — your entry will be saved and submitted automatically when reconnected.
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
@@ -172,6 +202,17 @@
|
||||
<!-- Bootstrap JS (optional) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"></script>
|
||||
|
||||
<script src="/static/offline-queue.js"></script>
|
||||
|
||||
<!-- Service worker registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta http-equiv="refresh" content="4;url=/" />
|
||||
<title>{{ site_title }}</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/static/images/logo.png" />
|
||||
<meta name="theme-color" content="#3a9cb8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="{{ site_title }}" />
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Vollkorn', serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.scrolling-wrapper {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.scrolling-content {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
font-size: 1.25rem;
|
||||
animation: scroll-left linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scroll-left {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.thank-you-message {
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container mt-5 mb-5">
|
||||
<header class="d-flex align-items-center mb-4">
|
||||
<img src="{{ logo_url }}" alt="Logo" class="me-3" style="height: 50px;" />
|
||||
<h1 class="h3 mb-0">{{ site_title }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<div class="alert alert-success py-4" role="alert">
|
||||
<p class="thank-you-message mb-1">Thanks, {{ name }}!</p>
|
||||
{% if offline %}
|
||||
<p class="mb-0">You’re currently offline — your entry has been saved and will sync automatically when reconnected.</p>
|
||||
{% else %}
|
||||
<p class="mb-0">Your visit has been recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-muted mt-3">
|
||||
Returning to the form in <span id="countdown">4</span>...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrolling Guest Entries at the Bottom -->
|
||||
<div class="scrolling-wrapper">
|
||||
<div class="scrolling-content">
|
||||
{% for guest in guests %}
|
||||
<span class="me-5">
|
||||
<strong>{{ guest[0] }}</strong> from {{ guest[1] }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for guest in guests %}
|
||||
<span class="me-5">
|
||||
<strong>{{ guest[0] }}</strong> from {{ guest[1] }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const pixelsPerSecond = 80;
|
||||
const content = document.querySelector(".scrolling-content");
|
||||
function updateScrollSpeed() {
|
||||
const oneCopyWidth = content.offsetWidth / 2;
|
||||
content.style.animationDuration = (oneCopyWidth / pixelsPerSecond) + "s";
|
||||
}
|
||||
updateScrollSpeed();
|
||||
window.addEventListener("resize", updateScrollSpeed);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var n = 4;
|
||||
var el = document.getElementById('countdown');
|
||||
var timer = setInterval(function () {
|
||||
n--;
|
||||
if (el) el.textContent = n;
|
||||
if (n <= 0) {
|
||||
clearInterval(timer);
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/static/offline-queue.js"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user