feat: Hermes prompt callback endpoint + API key management
- Add /api/prompt/<rid> 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.
This commit is contained in:
parent
e46893e0b4
commit
6a60aa686c
8 changed files with 242 additions and 35 deletions
|
|
@ -17,6 +17,7 @@
|
||||||
# PRICE_PER_VERSION - shown on the receipt page (default 10.00)
|
# PRICE_PER_VERSION - shown on the receipt page (default 10.00)
|
||||||
# CURRENCY - currency label (default CAD)
|
# CURRENCY - currency label (default CAD)
|
||||||
# MAX_REVISIONS - default customer revision limit (default 2)
|
# 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)
|
# DATABASE - SQLite database path inside the container (default /app/data/booth.db)
|
||||||
# UPLOAD_FOLDER - directory for uploaded MP3s inside the container (default /app/uploads)
|
# UPLOAD_FOLDER - directory for uploaded MP3s inside the container (default /app/uploads)
|
||||||
# SMTP_HOST - outgoing mail server (default mailroot8.namespro.ca)
|
# 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
|
PRICE_PER_VERSION=10.00
|
||||||
CURRENCY=CAD
|
CURRENCY=CAD
|
||||||
MAX_REVISIONS=2
|
MAX_REVISIONS=2
|
||||||
|
HERMES_API_KEY=
|
||||||
DATABASE=/app/data/booth.db
|
DATABASE=/app/data/booth.db
|
||||||
UPLOAD_FOLDER=/app/uploads
|
UPLOAD_FOLDER=/app/uploads
|
||||||
|
|
|
||||||
37
README.md
37
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.
|
- **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/<id>`):
|
- **Per-request detail page** (`/admin/request/<id>`):
|
||||||
- Generate and save a Suno prompt from customer info.
|
- 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.
|
- Edit the customer's email address if they mistyped it.
|
||||||
- Upload Version A and Version B MP3s (with automatic ID3 metadata tagging).
|
- Upload Version A and Version B MP3s (with automatic ID3 metadata tagging).
|
||||||
- Send a preview email with a private player link.
|
- 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 dashboard auto-refresh interval.
|
||||||
- Configure SMTP host/port/user/from; store the SMTP password encrypted.
|
- Configure SMTP host/port/user/from; store the SMTP password encrypted.
|
||||||
- Configure default MP3 metadata tags (artist, album, year, comment).
|
- 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.
|
- Send a test email.
|
||||||
- Download or restore the SQLite database backup.
|
- Download or restore the SQLite database backup.
|
||||||
- Reset the entire system for a new event.
|
- Reset the entire system for a new event.
|
||||||
|
|
@ -46,7 +49,7 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pending` | Customer submitted a request; operator has not saved a prompt yet. |
|
| `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). |
|
| `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. |
|
| `revisions_requested` | Customer asked for changes; current files were archived. |
|
||||||
| `awaiting_payment` | Customer approved a version; waiting for operator to collect payment and deliver. |
|
| `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 |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, and DB maintenance helpers. |
|
| `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, and secrets. |
|
| `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, secrets, and `HERMES_API_KEY`. |
|
||||||
| `models.py` | SQLite schema and CRUD helpers. |
|
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. |
|
||||||
| `init_db.py` | Standalone script to create the database tables. |
|
| `init_db.py` | Run once after deploy: `python init_db.py`. |
|
||||||
| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. |
|
| `templates/admin/request.html` | Biggest template; prompt copy helpers and JS live here. Customer email and operator notes are editable here. |
|
||||||
| `templates/request.html` | Customer request form. |
|
| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar Reset System button. |
|
||||||
| `templates/thanks.html` | Post-submission confirmation. |
|
| `templates/admin/settings.html` | Maintenance, settings, backup/restore, reset, and Hermes API key management. |
|
||||||
| `templates/status.html` | Customer order status lookup. |
|
| `templates/status.html` | Customer order status lookup. |
|
||||||
| `templates/faq.html` | Customer FAQ page. |
|
| `templates/faq.html` | Customer FAQ page. |
|
||||||
| `templates/player.html` | Customer audio player, approval, and revision form. |
|
| `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`. |
|
| `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`. |
|
| `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. |
|
||||||
| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. |
|
| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. |
|
||||||
| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
|
|| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
|
||||||
| `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. |
|
|| `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. |
|
||||||
| `CURRENCY` | No | Currency label, default `CAD`. |
|
|| `CURRENCY` | No | Currency label, default `CAD`. |
|
||||||
| `MAX_REVISIONS` | No | Default customer revision limit if not changed in settings, default `2`. |
|
|| `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.
|
5. Deploy the stack.
|
||||||
6. Open a console in the `theme-song-booth` container and run once:
|
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.
|
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
|
### Updating the deployment
|
||||||
|
|
||||||
|
|
@ -136,13 +142,14 @@ After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth`
|
||||||
## Important notes
|
## Important notes
|
||||||
|
|
||||||
- **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error.
|
- **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.
|
- **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.
|
- **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`.
|
- **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/<id>`. 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.
|
- **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.
|
- **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
|
## Common troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
24
REVIEW.md
24
REVIEW.md
|
|
@ -27,13 +27,13 @@ Flask app that lets convention attendees request custom AI-generated theme songs
|
||||||
|
|
||||||
| File | Notes |
|
| File | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health. |
|
| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health, and the `/api/prompt/<id>` Hermes callback endpoint. |
|
||||||
| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. |
|
| `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. |
|
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. |
|
||||||
| `init_db.py` | Run once after deploy: `python init_db.py`. |
|
| `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/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/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/faq.html` | Customer FAQ page. |
|
||||||
| `templates/status.html` | Customer order status lookup. |
|
| `templates/status.html` | Customer order status lookup. |
|
||||||
| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. |
|
| `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`.
|
1. Customer fills `/request`.
|
||||||
2. Open `/admin`, click request row (or filter by status).
|
2. Open `/admin`, click request row (or filter by status).
|
||||||
3. On `/admin/request/<id>`, fix the customer's email if needed, then click **Copy customer info for Hermes**, paste result to Hermes.
|
3. On `/admin/request/<id>`, 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**.
|
4. Hermes POSTs Title/Style/Lyrics back to the signed callback URL; the request becomes **Prompt Ready**.
|
||||||
5. Copy Style/Lyrics into Suno Custom Mode, generate two versions.
|
5. If the callback fails, paste Hermes' response into the Title/Style/Lyrics fields and click **Save Prompt**.
|
||||||
6. Upload Version A and B MP3s.
|
6. Copy Style/Lyrics into Suno Custom Mode, generate two versions.
|
||||||
7. Click **Send Preview Link**.
|
7. Upload Version A and B MP3s.
|
||||||
8. Customer receives email, visits player, picks version.
|
8. Click **Send Preview Link**.
|
||||||
9. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**.
|
9. Customer receives email, visits player, picks version.
|
||||||
10. Customer receives MP3 attachment(s) by email.
|
10. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**.
|
||||||
|
11. Customer receives MP3 attachment(s) by email.
|
||||||
|
|
||||||
## Environment variables that matter
|
## Environment variables that matter
|
||||||
|
|
||||||
|
|
@ -71,6 +72,7 @@ BOOTH_NAME
|
||||||
HOST_PORT
|
HOST_PORT
|
||||||
INTERNAL_PORT
|
INTERNAL_PORT
|
||||||
MAX_REVISIONS
|
MAX_REVISIONS
|
||||||
|
HERMES_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
Most can be overridden at runtime from `/admin/settings` and stored in `booth_settings.json`.
|
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`).
|
- 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.
|
- 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.
|
- 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
|
## How to redeploy
|
||||||
|
|
||||||
|
|
|
||||||
154
app.py
154
app.py
|
|
@ -45,8 +45,12 @@ from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
# Flask and related imports
|
# 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 import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
@ -263,6 +267,82 @@ def get_refresh_seconds():
|
||||||
val = 10
|
val = 10
|
||||||
return val if val in (10, 20, 30) else 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):
|
def send_email(to, subject, body, attachments=None, inline_images=None):
|
||||||
"""Send an email using the configured or runtime SMTP settings."""
|
"""Send an email using the configured or runtime SMTP settings."""
|
||||||
cfg = get_email_config()
|
cfg = get_email_config()
|
||||||
|
|
@ -383,6 +463,59 @@ def request_form():
|
||||||
return render_template('request.html', form=None)
|
return render_template('request.html', form=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/prompt/<int:rid>', 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/<int:rid>')
|
@app.route('/thanks/<int:rid>')
|
||||||
def thanks(rid):
|
def thanks(rid):
|
||||||
"""Confirmation page shown after a customer submits a request."""
|
"""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 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/<int:rid>/delete', methods=['POST'])
|
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
|
||||||
|
|
@ -765,6 +898,12 @@ def admin_settings():
|
||||||
'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')),
|
'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.
|
# Compute upload folder stats.
|
||||||
total_upload_size = 0
|
total_upload_size = 0
|
||||||
upload_file_count = 0
|
upload_file_count = 0
|
||||||
|
|
@ -948,6 +1087,14 @@ def admin_settings():
|
||||||
flash(f'Database restore failed: {e}', 'error')
|
flash(f'Database restore failed: {e}', 'error')
|
||||||
return redirect(url_for('admin_settings'))
|
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(
|
return render_template(
|
||||||
'admin/settings.html',
|
'admin/settings.html',
|
||||||
health=health,
|
health=health,
|
||||||
|
|
@ -965,6 +1112,9 @@ def admin_settings():
|
||||||
booth_open=booth_open,
|
booth_open=booth_open,
|
||||||
email_form=email_form,
|
email_form=email_form,
|
||||||
metadata=runtime_settings,
|
metadata=runtime_settings,
|
||||||
|
hermes_key_masked=hermes_key_masked,
|
||||||
|
hermes_key_set=hermes_key_set,
|
||||||
|
hermes_key_just_generated=hermes_key_just_generated,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ class Config:
|
||||||
# Default customer revision limit if not overridden in runtime settings.
|
# Default customer revision limit if not overridden in runtime settings.
|
||||||
MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2'))
|
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 used in customer-facing text and email sign-offs.
|
||||||
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
|
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
#
|
#
|
||||||
# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL
|
# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL
|
||||||
# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION,
|
# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION,
|
||||||
# CURRENCY, MAX_REVISIONS, DATABASE, UPLOAD_FOLDER, SMTP_HOST,
|
# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER,
|
||||||
# SMTP_PORT, SMTP_USER, SMTP_FROM
|
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM
|
||||||
#
|
#
|
||||||
# Named volumes keep the SQLite database and uploaded MP3s persistent
|
# Named volumes keep the SQLite database and uploaded MP3s persistent
|
||||||
# across container restarts and redeploys.
|
# across container restarts and redeploys.
|
||||||
|
|
@ -34,6 +34,7 @@ services:
|
||||||
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
|
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
|
||||||
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||||
- MAX_REVISIONS=${MAX_REVISIONS:-2}
|
- MAX_REVISIONS=${MAX_REVISIONS:-2}
|
||||||
|
- HERMES_API_KEY=${HERMES_API_KEY:-}
|
||||||
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
|
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
|
||||||
- CURRENCY=${CURRENCY:-CAD}
|
- CURRENCY=${CURRENCY:-CAD}
|
||||||
- DATABASE=${DATABASE:-/app/data/booth.db}
|
- DATABASE=${DATABASE:-/app/data/booth.db}
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,10 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function copyPromptForHermes() {
|
function copyPromptForHermes() {
|
||||||
|
const callbackUrl = {{ callback_url | tojson }};
|
||||||
const data = {
|
const data = {
|
||||||
|
request_id: {{ req.id | tojson }},
|
||||||
|
email: {{ req.email | tojson }},
|
||||||
name: {{ req.name | tojson }},
|
name: {{ req.name | tojson }},
|
||||||
hobbies: {{ req.hobbies | tojson }},
|
hobbies: {{ req.hobbies | tojson }},
|
||||||
notable_facts: {{ req.notable_facts | tojson }},
|
notable_facts: {{ req.notable_facts | tojson }},
|
||||||
|
|
@ -330,8 +333,8 @@
|
||||||
vocal_gender: {{ req.vocal_gender | tojson }},
|
vocal_gender: {{ req.vocal_gender | tojson }},
|
||||||
extra_requests: {{ req.extra_requests | tojson }}
|
extra_requests: {{ req.extra_requests | tojson }}
|
||||||
};
|
};
|
||||||
const text = `Please write a Suno Custom Mode prompt for this customer. Suggest a song title too.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\nExtra requests: ${data.extra_requests || '-'}\n\nReply with exactly this format:\n\nTitle: [suggested title]\n\nStyle:\n[the style description here]\n\nLyrics:\n[the lyrics here, with metatags]`;
|
const text = `Booth request: ${data.request_id}\nCustomer email: ${data.email}\nCallback URL: ${callbackUrl}\n\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\nExtra requests: ${data.extra_requests || '-'}\n\nExpected JSON response format:\n{\n \"request_id\": ${data.request_id},\n \"email\": \"${data.email}\",\n \"suno_title\": \"...\",\n \"suno_style\": \"...\",\n \"suno_lyrics\": \"...\"\n}`;
|
||||||
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste Hermes response into the Title, Style, and Lyrics fields."));
|
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(elementId, label) {
|
function copyToClipboard(elementId, label) {
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,28 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email / SMTP configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Hermes API Key</h2>
|
||||||
|
<p class="copy-hint">Hermes uses this key in the Authorization header when POSTing generated Suno prompts back to the callback endpoint.</p>
|
||||||
|
<p><strong>Current key:</strong> <code>{{ hermes_key_masked }}</code></p>
|
||||||
|
|
||||||
|
{% if hermes_key_just_generated %}
|
||||||
|
<div class="flash success" style="background:#064e3b">
|
||||||
|
<p><strong>New key (copy it now — it will not be shown again):</strong></p>
|
||||||
|
<p class="path" id="new-hermes-key">{{ hermes_key_just_generated }}</p>
|
||||||
|
<button type="button" onclick="copyHermesKey()">Copy to clipboard</button>
|
||||||
|
</div>
|
||||||
|
{% elif not hermes_key_set %}
|
||||||
|
<p class="status-bad">⚠️ No Hermes API key is configured. Generate one before using the callback workflow.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" onsubmit="return confirm('Regenerating the key will invalidate the old one. Hermes will need the new key. Continue?')">
|
||||||
|
<input type="hidden" name="action" value="regenerate_hermes_key">
|
||||||
|
<button type="submit">Regenerate API Key</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Email / SMTP configuration -->
|
<!-- Email / SMTP configuration -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Email (SMTP) Settings</h2>
|
<h2>Email (SMTP) Settings</h2>
|
||||||
|
|
@ -351,8 +373,22 @@
|
||||||
<button type="submit">Reset System</button>
|
<button type="submit">Reset System</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
<script>
|
||||||
</div>
|
function copyHermesKey() {
|
||||||
|
const el = document.getElementById('new-hermes-key');
|
||||||
|
if (!el) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(el);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
navigator.clipboard.writeText(el.textContent).then(() => {
|
||||||
|
alert('Hermes API key copied!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Reference in a new issue