From 6a60aa686cf9ee6cc61eb93fabb7e3c43fd6684c Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Mon, 3 Aug 2026 17:16:56 +0000 Subject: [PATCH] feat: Hermes prompt callback endpoint + API key management - Add /api/prompt/ callback endpoint with dual auth: per-request signed URL token + Bearer API key. - Add HERMES_API_KEY config and runtime key helpers in app.py. - Update admin request page to copy request details + callback URL. - Add API key management to admin settings: regenerate, mask, show-once. - Update .env.example, docker-compose.yml, README, REVIEW docs. --- .env.example | 2 + README.md | 37 ++++---- REVIEW.md | 24 +++--- app.py | 154 +++++++++++++++++++++++++++++++++- config.py | 4 + docker-compose.yml | 5 +- templates/admin/request.html | 7 +- templates/admin/settings.html | 44 +++++++++- 8 files changed, 242 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index f9593af..585377c 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ # PRICE_PER_VERSION - shown on the receipt page (default 10.00) # CURRENCY - currency label (default CAD) # MAX_REVISIONS - default customer revision limit (default 2) +# HERMES_API_KEY - API key Hermes uses to POST prompts back (can be generated from /admin/settings) # DATABASE - SQLite database path inside the container (default /app/data/booth.db) # UPLOAD_FOLDER - directory for uploaded MP3s inside the container (default /app/uploads) # SMTP_HOST - outgoing mail server (default mailroot8.namespro.ca) @@ -39,5 +40,6 @@ HOST_PORT=127.0.0.1:8000 PRICE_PER_VERSION=10.00 CURRENCY=CAD MAX_REVISIONS=2 +HERMES_API_KEY= DATABASE=/app/data/booth.db UPLOAD_FOLDER=/app/uploads diff --git a/README.md b/README.md index 4fd2671..633ea56 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ A Flask web app for a convention booth where visitors request a custom AI-genera - **Dashboard queue** (`/admin`) — filter by status (All, Pending, Needs Upload, Awaiting Payment, Delivered) and auto-refresh at a configurable interval. - **Per-request detail page** (`/admin/request/`): - Generate and save a Suno prompt from customer info. + - One-click copy of request details + a signed callback URL for Hermes. + - Hermes POSTs back the generated Title/Style/Lyrics; status becomes **Prompt Ready** automatically. - Edit the customer's email address if they mistyped it. - Upload Version A and Version B MP3s (with automatic ID3 metadata tagging). - Send a preview email with a private player link. @@ -32,6 +34,7 @@ A Flask web app for a convention booth where visitors request a custom AI-genera - Configure dashboard auto-refresh interval. - Configure SMTP host/port/user/from; store the SMTP password encrypted. - Configure default MP3 metadata tags (artist, album, year, comment). + - Generate/regenerate the Hermes API key used by the prompt callback endpoint (shown once, otherwise masked). - Send a test email. - Download or restore the SQLite database backup. - Reset the entire system for a new event. @@ -46,7 +49,7 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de | Status | Meaning | |---|---| | `pending` | Customer submitted a request; operator has not saved a prompt yet. | -| `prompt_ready` | Operator saved Title/Style/Lyrics. | +| `prompt_ready` | Hermes POSTed back the generated Suno prompt, or the operator saved it manually. | | `songs_uploaded` | Both MP3s uploaded; preview link can be sent. Dashboard filter label: **Needs Upload** (shown for this state when filtering). | | `revisions_requested` | Customer asked for changes; current files were archived. | | `awaiting_payment` | Customer approved a version; waiting for operator to collect payment and deliver. | @@ -57,13 +60,13 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de | File | Purpose | |---|---| -| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, and DB maintenance helpers. | -| `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, and secrets. | -| `models.py` | SQLite schema and CRUD helpers. | -| `init_db.py` | Standalone script to create the database tables. | -| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. | -| `templates/request.html` | Customer request form. | -| `templates/thanks.html` | Post-submission confirmation. | +| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, DB maintenance, and Hermes callback endpoint. | +| `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, secrets, and `HERMES_API_KEY`. | +| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. | +| `init_db.py` | Run once after deploy: `python init_db.py`. | +| `templates/admin/request.html` | Biggest template; prompt copy helpers and JS live here. Customer email and operator notes are editable here. | +| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar Reset System button. | +| `templates/admin/settings.html` | Maintenance, settings, backup/restore, reset, and Hermes API key management. | | `templates/status.html` | Customer order status lookup. | | `templates/faq.html` | Customer FAQ page. | | `templates/player.html` | Customer audio player, approval, and revision form. | @@ -114,10 +117,11 @@ Visit: | `PUBLIC_BASE_URL` | Yes | Public HTTPS URL, e.g. `https://booth.dionysismedia.ca`. | | `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. | | `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. | -| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. | -| `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. | -| `CURRENCY` | No | Currency label, default `CAD`. | -| `MAX_REVISIONS` | No | Default customer revision limit if not changed in settings, default `2`. | +|| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. | +|| `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. | +|| `CURRENCY` | No | Currency label, default `CAD`. | +|| `MAX_REVISIONS` | No | Default customer revision limit if not changed in settings, default `2`. | +|| `HERMES_API_KEY` | No | API key for the `/api/prompt` callback. If omitted, generate one from `/admin/settings`. | 5. Deploy the stack. 6. Open a console in the `theme-song-booth` container and run once: @@ -127,7 +131,9 @@ python init_db.py ``` 7. Point your reverse proxy at the `HOST_PORT` you chose. -8. Print or display a QR code pointing to `https://your-domain/request`. +8. Visit `/admin/settings` and click **Regenerate API Key** to create the Hermes callback key. +9. Update your Hermes skill or config with the new key and the booth public URL. +10. Print or display a QR code pointing to `https://your-domain/request`. ### Updating the deployment @@ -136,13 +142,14 @@ After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth` ## Important notes - **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error. -- **Runtime settings persist.** SMTP config, revision limit, auto-refresh interval, booth open/closed state, and MP3 metadata defaults are stored in `booth_settings.json` inside the persistent uploads volume. They survive redeploys. +- **Runtime settings persist.** SMTP config, revision limit, auto-refresh interval, booth open/closed state, Hermes API key, and MP3 metadata defaults are stored in `booth_settings.json` inside the persistent uploads volume. They survive redeploys. - **Booth open/closed switch.** Operators can flip the booth status from `/admin/settings`. When closed, `/request` shows a closed banner and message instead of the form. - **Payments are manual.** The app records a Square payment reference but does not integrate with Square's API. Use a Square Terminal/Reader at the booth. - **Operator queue is the dashboard.** No operator email alerts are sent; approvals and revision notes appear as status changes in `/admin`. +- **Hermes callback workflow.** Operators copy request details + a signed callback URL from `/admin/request/`. Hermes POSTs back Title/Style/Lyrics; the record becomes `prompt_ready`. - **MP3 metadata.** Uploaded files are tagged with title (from the saved prompt), plus configured artist/album/year/comment values. - **Email logo.** `static/DM-Logo_email.png` is attached inline to all customer emails as the Dionysis Media signature. -- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment. +- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment. The Hermes API key is stored encrypted in `booth_settings.json` and masked in the admin UI. ## Common troubleshooting diff --git a/REVIEW.md b/REVIEW.md index 4ab21d6..f636a71 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -27,13 +27,13 @@ Flask app that lets convention attendees request custom AI-generated theme songs | File | Notes | |---|---| -| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health. | -| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. | +| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health, and the `/api/prompt/` Hermes callback endpoint. | +| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. `HERMES_API_KEY` can be overridden at runtime. | | `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. | | `init_db.py` | Run once after deploy: `python init_db.py`. | | `templates/admin/request.html` | Biggest template; prompt copy helpers and JS live here. Customer email and operator notes are editable here. | | `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar Reset System button. | -| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore, health check, reset. | +| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore, health check, reset, and Hermes API key management. | | `templates/faq.html` | Customer FAQ page. | | `templates/status.html` | Customer order status lookup. | | `templates/closed.html` | Message shown on `/request` when the booth is marked closed. | @@ -52,13 +52,14 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de 1. Customer fills `/request`. 2. Open `/admin`, click request row (or filter by status). 3. On `/admin/request/`, fix the customer's email if needed, then click **Copy customer info for Hermes**, paste result to Hermes. -4. Paste Hermes response (Title/Style/Lyrics format) into the fields and click **Save Prompt**. -5. Copy Style/Lyrics into Suno Custom Mode, generate two versions. -6. Upload Version A and B MP3s. -7. Click **Send Preview Link**. -8. Customer receives email, visits player, picks version. -9. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**. -10. Customer receives MP3 attachment(s) by email. +4. Hermes POSTs Title/Style/Lyrics back to the signed callback URL; the request becomes **Prompt Ready**. +5. If the callback fails, paste Hermes' response into the Title/Style/Lyrics fields and click **Save Prompt**. +6. Copy Style/Lyrics into Suno Custom Mode, generate two versions. +7. Upload Version A and B MP3s. +8. Click **Send Preview Link**. +9. Customer receives email, visits player, picks version. +10. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**. +11. Customer receives MP3 attachment(s) by email. ## Environment variables that matter @@ -71,6 +72,7 @@ BOOTH_NAME HOST_PORT INTERNAL_PORT MAX_REVISIONS +HERMES_API_KEY ``` Most can be overridden at runtime from `/admin/settings` and stored in `booth_settings.json`. @@ -84,6 +86,8 @@ Most can be overridden at runtime from `/admin/settings` and stored in `booth_se - Runtime settings are stored in the persistent uploads volume (`booth_settings.json`). - The `booth_open` setting controls whether `/request` shows the form or the closed banner. - Container cannot read host paths; all static assets used at runtime (logo, banner, favicons, closed banner) must be in the repo or a mounted volume. +- The Hermes callback URL is signed with `APP_SECRET_KEY` and expires after 1 hour. +- If you regenerate the Hermes API key, update the Hermes skill/config immediately; old key requests will 401. ## How to redeploy diff --git a/app.py b/app.py index c7f5673..30f8990 100644 --- a/app.py +++ b/app.py @@ -45,8 +45,12 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import base64 +import hmac +import hashlib +import secrets + # Flask and related imports -from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file +from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file, jsonify from flask_limiter import Limiter from flask_limiter.util import get_remote_address from werkzeug.utils import secure_filename @@ -263,6 +267,82 @@ def get_refresh_seconds(): val = 10 return val if val in (10, 20, 30) else 10 + +def get_hermes_api_key(): + """ + Return the effective Hermes API key. + Runtime settings override the environment variable so the key can be + regenerated from /admin/settings without redeploying. + """ + cfg = load_booth_settings() + return decrypt_value(cfg.get('hermes_api_key', '')) or current_app.config.get('HERMES_API_KEY', '') + + +def set_hermes_api_key(key): + """Persist a new Hermes API key (encrypted) to runtime settings.""" + cfg = load_booth_settings() + cfg['hermes_api_key'] = encrypt_value(key) + save_booth_settings(cfg) + + +def generate_hermes_api_key(): + """Generate a new random API key for Hermes callback authentication.""" + return secrets.token_urlsafe(32) + + +def mask_api_key(key): + """Return a masked version of the API key showing only the last 6 characters.""" + if not key: + return 'Not set' + if len(key) <= 6: + return '*' * len(key) + return '*' * (len(key) - 6) + key[-6:] + + +def sign_prompt_callback(rid, expires_at=None): + """ + Create a signed callback token for a specific request ID. + The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY. + Returns a URL-safe token string. + """ + secret = current_app.config['SECRET_KEY'].encode() + if expires_at is None: + # Default expiry: 1 hour. + expires_at = int(time.time()) + 3600 + payload = f"{rid}:{expires_at}" + sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16] + return f"{rid}:{expires_at}:{sig}" + + +def verify_prompt_callback(token): + """ + Verify a signed callback token. Returns (rid, ok) tuple. + The token must match the HMAC signature and not be expired. + """ + if not token: + return None, False + parts = token.split(':') + if len(parts) != 3: + return None, False + try: + rid = int(parts[0]) + expires_at = int(parts[1]) + except ValueError: + return None, False + if int(time.time()) > expires_at: + return None, False + expected = sign_prompt_callback(rid, expires_at) + if not hmac.compare_digest(expected, token): + return None, False + return rid, True + + +def build_prompt_callback_url(rid): + """Build the full callback URL an operator pastes into Hermes for a request.""" + token = sign_prompt_callback(rid) + return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}" + + def send_email(to, subject, body, attachments=None, inline_images=None): """Send an email using the configured or runtime SMTP settings.""" cfg = get_email_config() @@ -383,6 +463,59 @@ def request_form(): return render_template('request.html', form=None) +@app.route('/api/prompt/', methods=['POST']) +def api_update_prompt(rid): + """ + Hermes callback endpoint. + + Accepts a JSON POST with generated Suno prompt fields and updates the + matching request. Two layers of auth: + 1) A per-request signed callback token in the query string. + 2) A Hermes API key in the Authorization header (Bearer). + + Only records in 'pending' status can be updated. On success, status is set + to 'prompt_ready'. + """ + # Layer 1: verify the signed callback URL token. + callback_token = request.args.get('token', '').strip() + token_rid, token_ok = verify_prompt_callback(callback_token) + if not token_ok or token_rid != rid: + abort(401) + + # Layer 2: verify the Hermes API key from the Authorization header. + expected_key = get_hermes_api_key() + if not expected_key: + abort(500, description='Hermes API key is not configured') + auth_header = request.headers.get('Authorization', '').strip() + if not auth_header.startswith('Bearer '): + abort(401) + provided_key = auth_header[7:].strip() + if not hmac.compare_digest(expected_key, provided_key): + abort(401) + + req = get_request_by_id(rid) + if not req: + abort(404) + if req['status'] != 'pending': + abort(409, description='Request is no longer pending') + + data = request.get_json(silent=True) or {} + + # Optional email verification to make sure clipboard matches record. + provided_email = data.get('email', '').strip().lower() + if provided_email and provided_email != req['email'].lower(): + abort(400, description='Email mismatch') + + update_request(rid, + suno_title=data.get('suno_title', '').strip(), + suno_style=data.get('suno_style', '').strip(), + suno_lyrics=data.get('suno_lyrics', '').strip(), + status='prompt_ready' + ) + + return jsonify({'ok': True, 'request_id': rid, 'status': 'prompt_ready'}), 200 + + @app.route('/thanks/') def thanks(rid): """Confirmation page shown after a customer submits a request.""" @@ -692,7 +825,7 @@ def admin_request(rid): return redirect(url_for('admin_request', rid=rid)) - return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files) + return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files, callback_url=build_prompt_callback_url(rid)) @app.route('/admin/request//delete', methods=['POST']) @@ -765,6 +898,12 @@ def admin_settings(): 'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')), } + # Hermes API key state for the settings page. + hermes_key = get_hermes_api_key() + hermes_key_masked = mask_api_key(hermes_key) + hermes_key_set = bool(hermes_key) + hermes_key_just_generated = session.pop('hermes_key_just_generated', None) + # Compute upload folder stats. total_upload_size = 0 upload_file_count = 0 @@ -948,6 +1087,14 @@ def admin_settings(): flash(f'Database restore failed: {e}', 'error') return redirect(url_for('admin_settings')) + elif action == 'regenerate_hermes_key': + # Generate a new Hermes API key, persist it, and show it exactly once. + new_key = generate_hermes_api_key() + set_hermes_api_key(new_key) + session['hermes_key_just_generated'] = new_key + flash('Hermes API key regenerated. Copy it now — it will not be shown again.', 'success') + return redirect(url_for('admin_settings')) + return render_template( 'admin/settings.html', health=health, @@ -965,6 +1112,9 @@ def admin_settings(): booth_open=booth_open, email_form=email_form, metadata=runtime_settings, + hermes_key_masked=hermes_key_masked, + hermes_key_set=hermes_key_set, + hermes_key_just_generated=hermes_key_just_generated, ) diff --git a/config.py b/config.py index 543808c..3aaeedf 100644 --- a/config.py +++ b/config.py @@ -69,6 +69,10 @@ class Config: # Default customer revision limit if not overridden in runtime settings. MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2')) + # API key used by Hermes to POST generated Suno prompts back to the website. + # Also stored (encrypted) in runtime settings so it can be regenerated from /admin/settings. + HERMES_API_KEY = os.environ.get('HERMES_API_KEY', '') + # Booth name used in customer-facing text and email sign-offs. BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs') diff --git a/docker-compose.yml b/docker-compose.yml index f2917cc..bc0ad28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ # # Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL # Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION, -# CURRENCY, MAX_REVISIONS, DATABASE, UPLOAD_FOLDER, SMTP_HOST, -# SMTP_PORT, SMTP_USER, SMTP_FROM +# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER, +# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM # # Named volumes keep the SQLite database and uploaded MP3s persistent # across container restarts and redeploys. @@ -34,6 +34,7 @@ services: - BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs} - INTERNAL_PORT=${INTERNAL_PORT:-8000} - MAX_REVISIONS=${MAX_REVISIONS:-2} + - HERMES_API_KEY=${HERMES_API_KEY:-} - PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00} - CURRENCY=${CURRENCY:-CAD} - DATABASE=${DATABASE:-/app/data/booth.db} diff --git a/templates/admin/request.html b/templates/admin/request.html index 1c61a21..05e87a0 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -322,7 +322,10 @@ + +