feat: public demo mode + base-path proxy + security hardening

- Add PUBLIC_DEMO_MODE guards on all mutating routes (/settings POST, /create POST, /background/add, /background/delete, /videos/delete)
- Add PrefixMiddleware for PUBLIC_BASE_PATH support with 308 redirect
- Add app_url() context processor for template link generation
- Add client-side demo mode UI disabling (data-demo-disabled attribute)
- Add PUBLIC_BASE_PATH + appPath() JS helpers in layout.html
- Add redacted secrets in settings page (_redact_secrets with api_url/api_key)
- Fix CSRF check for Vercel proxy (PUBLIC_ORIGIN_HOST env var support)
- Bump Fly resources to 4GB RAM / 2 CPUs for FFmpeg+Playwright
- Add fly.toml with PUBLIC_BASE_PATH, PUBLIC_DEMO_MODE, PUBLIC_ORIGIN_HOST
pull/2556/head
Hong Phuc 2 days ago
parent 1186efbe4d
commit f97b0cc881

108
GUI.py

@ -5,8 +5,9 @@ import sys
import threading
import time
import webbrowser
from copy import deepcopy
from copy import deepcopy
from pathlib import Path
from urllib.parse import urlparse
# Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump"
import tomlkit
@ -21,10 +22,11 @@ from flask import (
send_from_directory,
url_for,
)
from werkzeug.wrappers import Response
import utils.gui_utils as gui
from utils.docker_bootstrap import ensure_runtime_state
from utils.settings import apply_template_defaults
from utils.settings import apply_template_defaults
ensure_runtime_state()
@ -33,6 +35,10 @@ HOST = os.environ.get("GUI_HOST", "0.0.0.0")
PORT = int(os.environ.get("GUI_PORT", "4000"))
OPEN_BROWSER = os.environ.get("GUI_OPEN_BROWSER", "1").lower() in {"1", "true", "yes", "on"}
BROWSER_URL = os.environ.get("GUI_BROWSER_URL", f"http://localhost:{PORT}")
PUBLIC_BASE_PATH = "/" + os.environ.get("PUBLIC_BASE_PATH", "").strip("/")
if PUBLIC_BASE_PATH == "/":
PUBLIC_BASE_PATH = ""
PUBLIC_DEMO_MODE = os.environ.get("PUBLIC_DEMO_MODE", "0").lower() in {"1", "true", "yes", "on"}
# Configure application
app = Flask(__name__, template_folder="GUI")
@ -41,6 +47,42 @@ app = Flask(__name__, template_folder="GUI")
app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(32)
class PrefixMiddleware:
def __init__(self, app, prefix: str):
self.app = app
self.prefix = prefix
def __call__(self, environ, start_response):
if not self.prefix:
return self.app(environ, start_response)
path_info = environ.get("PATH_INFO", "")
if path_info == self.prefix:
response = Response("", status=308, headers={"Location": f"{self.prefix}/"})
return response(environ, start_response)
if path_info.startswith(f"{self.prefix}/"):
environ["SCRIPT_NAME"] = self.prefix
environ["PATH_INFO"] = path_info[len(self.prefix):] or "/"
return self.app(environ, start_response)
app.wsgi_app = PrefixMiddleware(app.wsgi_app, PUBLIC_BASE_PATH)
@app.context_processor
def inject_public_context():
def app_url(path: str) -> str:
normalized = path if path.startswith("/") else f"/{path}"
return f"{request.script_root}{normalized}"
return {
"app_url": app_url,
"public_base_path": request.script_root,
"public_demo_mode": PUBLIC_DEMO_MODE,
}
# Ensure responses aren't cached + security headers
@app.after_request
def after_request(response):
@ -58,14 +100,23 @@ def csrf_check():
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
origin = request.headers.get("Origin")
if origin:
# Allow same-origin only (localhost dev ports)
from urllib.parse import urlparse
# Allow same-origin + public proxy origin (e.g. Vercel rewrites)
origin_host = urlparse(origin).hostname
request_host = urlparse(request.host_url).hostname
if origin_host not in (request_host, "localhost", "127.0.0.1"):
allowed = {
urlparse(request.host_url).hostname,
"localhost",
"127.0.0.1",
*(os.environ.get("PUBLIC_ORIGIN_HOST", "").split(",") if os.environ.get("PUBLIC_ORIGIN_HOST") else []),
}
allowed.discard("") # remove empty string
if origin_host not in allowed:
return jsonify({"error": "CSRF check failed"}), 403
def public_demo_forbidden():
return jsonify({"error": "This action is disabled in public demo mode"}), 403
# Display index.html
@app.route("/")
def index():
@ -77,13 +128,15 @@ def backgrounds():
return render_template("backgrounds.html", file="backgrounds.json")
@app.route("/background/add", methods=["POST"])
def background_add():
# Get form values
youtube_uri = request.form.get("youtube_uri", "").strip()
filename = request.form.get("filename", "").strip()
citation = request.form.get("citation", "").strip()
position = request.form.get("position", "").strip()
@app.route("/background/add", methods=["POST"])
def background_add():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
# Get form values
youtube_uri = request.form.get("youtube_uri", "").strip()
filename = request.form.get("filename", "").strip()
citation = request.form.get("citation", "").strip()
position = request.form.get("position", "").strip()
gui.add_background(youtube_uri, filename, citation, position)
@ -92,6 +145,8 @@ def background_add():
@app.route("/background/delete", methods=["POST"])
def background_delete():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
key = request.form.get("background-key")
gui.delete_background(key)
@ -99,7 +154,8 @@ def background_delete():
_SENSITIVE_KEYS = {"password", "client_secret", "access_token", "2fa_secret",
"tiktok_sessionid", "elevenlabs_api_key", "openai_api_key"}
"tiktok_sessionid", "elevenlabs_api_key", "openai_api_key",
"api_url", "api_key"}
def _redact_secrets(data: dict) -> dict:
@ -113,18 +169,20 @@ def _redact_secrets(data: dict) -> dict:
@app.route("/settings", methods=["GET", "POST"])
def settings():
config_load = tomlkit.loads(Path("config.toml").read_text())
config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
# Get checks for all values
checks = gui.get_checks()
if request.method == "POST":
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
# Get data from form as dict
data = request.form.to_dict()
# Change settings
gui.modify_settings(data, config_load, checks)
config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
gui.modify_settings(data, config_load, checks)
config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
return render_template("settings.html", file="config.toml", data=_redact_secrets(config), checks=checks)
@ -183,6 +241,8 @@ def video_by_id(video_id):
# Delete one or more videos by ID
@app.route("/videos/delete", methods=["POST"])
def video_delete():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
data = request.get_json(silent=True) or {}
ids = data.get("ids", [])
if not ids or not isinstance(ids, list):
@ -243,8 +303,8 @@ def _run_pipeline(search_queries=None):
pipeline_state["scraper_events"] = []
try:
# Load config and merge template defaults for non-interactive GUI runs.
settings.config = settings.apply_template_defaults(toml.load("config.toml"))
# Load config and merge template defaults for non-interactive GUI runs.
settings.config = settings.apply_template_defaults(toml.load("config.toml"))
# Apply search_queries override if provided from UI
if search_queries:
@ -275,14 +335,14 @@ def _run_pipeline(search_queries=None):
import importlib
import video_creation.final_video
import video_creation.background
import video_creation.voices
import TTS.engine_wrapper
import video_creation.voices
import TTS.engine_wrapper
import platforms.threads.screenshot
import main
importlib.reload(video_creation.final_video)
importlib.reload(video_creation.background)
importlib.reload(TTS.engine_wrapper)
importlib.reload(video_creation.voices)
importlib.reload(TTS.engine_wrapper)
importlib.reload(video_creation.voices)
importlib.reload(platforms.threads.screenshot)
importlib.reload(main)
@ -306,6 +366,8 @@ def _run_pipeline(search_queries=None):
@app.route("/create", methods=["GET", "POST"])
def create():
if request.method == "POST":
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
if pipeline_state["running"]:
return jsonify({"status": "already_running"})
data = request.get_json(silent=True) or {}

@ -14,7 +14,7 @@
placeholder="Search..."
onkeyup="searchFilter()">
</div>
<button onclick="add_modal.showModal()" class="btn-primary-neo text-sm">
<button onclick="add_modal.showModal()" class="btn-primary-neo text-sm" data-demo-disabled>
<i data-lucide="plus" class="w-4 h-4"></i>
<span class="hidden sm:inline ml-1">Add Video</span>
</button>
@ -43,7 +43,7 @@
<form action="background/delete" method="post" class="flex gap-2">
<input type="hidden" id="background-key" name="background-key" value="">
<button type="button" onclick="delete_modal.close()" class="btn-ghost-neo">Cancel</button>
<button type="submit" class="btn-danger-neo text-sm">Delete</button>
<button type="submit" class="btn-danger-neo text-sm" data-demo-disabled>Delete</button>
</form>
</div>
</div>
@ -98,7 +98,7 @@
<div class="modal-action">
<button type="button" onclick="add_modal.close()" class="btn-ghost-neo">Cancel</button>
<button type="submit" class="btn-primary-neo text-sm">Add Background</button>
<button type="submit" class="btn-primary-neo text-sm" data-demo-disabled>Add Background</button>
</div>
</form>
</div>
@ -146,11 +146,11 @@
<h3 class="text-[#111111] font-mono text-sm font-medium truncate mb-1" title="${h(key)}">${h(key)}</h3>
<p class="text-[#111111]/40 font-mono text-xs truncate mb-4">${h(value[2])}</p>
<div class="flex justify-end">
${window.PUBLIC_DEMO_MODE ? '' : `<div class="flex justify-end">
<button onclick="confirmDelete('${key}')" class="btn-ghost-neo p-2 hover:text-[#DE6C56]">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>`}
</div>
</div>
`;

@ -30,9 +30,11 @@
<input id="keywords-input" type="text"
class="input-neo flex-1"
placeholder="news, politics, trending, viral"
value="{{ default_search_queries }}">
value="{{ default_search_queries }}"
data-demo-disabled>
<button id="clear-keywords" class="btn-ghost-neo"
onclick="document.getElementById('keywords-input').value=''" type="button">
onclick="document.getElementById('keywords-input').value=''" type="button"
data-demo-disabled>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
@ -91,7 +93,7 @@
<span class="font-display font-black uppercase tracking-tighter text-lg text-[#111111]">Generation Complete!</span>
</div>
<p id="done-msg" class="text-[#111111]/60 font-mono text-sm">Your video has been rendered and saved to the library.</p>
<a href="/" class="btn-secondary-neo text-sm">View Video</a>
<a href="{{ app_url('/') }}" class="btn-secondary-neo text-sm">View Video</a>
</div>
</div>
@ -454,7 +456,7 @@
const keywords = document.getElementById('keywords-input').value.trim();
try {
const r = await fetch('/create', {
const r = await fetch(window.appPath('/create'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ search_queries: keywords || null }),
@ -477,7 +479,7 @@
async function pollStatus() {
try {
const r = await fetch('/create/status');
const r = await fetch(window.appPath('/create/status'));
const state = await r.json();
const stageText = document.getElementById('stage-text');
@ -535,11 +537,14 @@
window.addEventListener('load', async function() {
lucide.createIcons();
try {
const r = await fetch('/create/status');
const r = await fetch(window.appPath('/create/status'));
const state = await r.json();
const btn = document.getElementById('create-btn');
if (state.running) {
if (window.PUBLIC_DEMO_MODE) {
btn.disabled = true;
document.getElementById('btn-text').textContent = 'Public Demo: Disabled';
} else if (state.running) {
document.getElementById('progress-area').classList.remove('hidden');
document.getElementById('log-area').classList.remove('hidden');
btn.disabled = true;
@ -558,8 +563,8 @@
}
} catch (err) {
console.error("Initial status check failed:", err);
document.getElementById('create-btn').disabled = false;
document.getElementById('btn-text').textContent = 'Start Generation';
document.getElementById('create-btn').disabled = window.PUBLIC_DEMO_MODE;
document.getElementById('btn-text').textContent = window.PUBLIC_DEMO_MODE ? 'Public Demo: Disabled' : 'Start Generation';
}
});
</script>

@ -27,11 +27,13 @@
<!-- Normal toolbar (hidden in select mode) -->
<div id="normal-toolbar" class="flex items-center gap-2 w-full md:w-auto">
{% if not public_demo_mode %}
<button type="button" onclick="toggleSelectMode()"
class="btn-ghost-neo shrink-0" style="height: 3rem; min-height: 3rem;">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
Select
</button>
{% endif %}
<div class="relative w-full md:w-72">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#111111]/40"></i>
<input type="text"
@ -177,7 +179,7 @@
title="View Source">
<i data-lucide="external-link" class="w-4 h-4"></i>
</a>
<a href="/video/${encodeURIComponent(v.id)}?download=1" download
<a href="${window.appPath('/video/' + encodeURIComponent(v.id))}?download=1" download
class="btn-ghost-neo p-2"
title="Download">
<i data-lucide="download" class="w-4 h-4"></i>
@ -190,11 +192,11 @@
title="Copy Link">
<i data-lucide="link" class="w-4 h-4"></i>
</button>
<button class="btn-ghost-neo p-2 hover:text-[#DE6C56] delete-btn"
${window.PUBLIC_DEMO_MODE ? '' : `<button class="btn-ghost-neo p-2 hover:text-[#DE6C56] delete-btn"
data-video-id="${h(v.id)}"
title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</button>`}
</div>
</div>
@ -216,7 +218,7 @@
cb.checked = !cb.checked;
updateSelectionCount();
} else {
openPlayer(`/video/${encodeURIComponent(btn.dataset.videoId)}`, btn.dataset.videoTitle);
openPlayer(window.appPath(`/video/${encodeURIComponent(btn.dataset.videoId)}`), btn.dataset.videoTitle);
}
});
});
@ -319,7 +321,7 @@
pendingDeleteIds = [];
try {
await fetch('/videos/delete', {
await fetch(window.appPath('/videos/delete'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })

@ -42,6 +42,20 @@
<!-- Lucide icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script>
window.PUBLIC_BASE_PATH = {{ public_base_path|tojson }};
window.PUBLIC_DEMO_MODE = {{ public_demo_mode|tojson }};
window.appPath = function(path) {
var normalized = path.charAt(0) === '/' ? path : '/' + path;
return window.PUBLIC_BASE_PATH + normalized;
};
document.addEventListener('DOMContentLoaded', function() {
if (!window.PUBLIC_DEMO_MODE) return;
document.querySelectorAll('[data-demo-disabled]').forEach(function(el) {
el.disabled = true;
});
});
</script>
<style>
/* ── Neo-Brutalist Global Overrides ───────────────────────────── */
@ -314,17 +328,17 @@
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-14">
<!-- Logo -->
<a href="/" class="flex items-center gap-2 no-underline">
<a href="{{ app_url('/') }}" class="flex items-center gap-2 no-underline">
<span class="font-display text-lg text-[#111111] tracking-tighter uppercase">VideoMakerBot</span>
</a>
<!-- Nav Items -->
<div class="flex items-center gap-0">
<a href="/" class="nav-link">Library</a>
<a href="/backgrounds" class="nav-link">Backgrounds</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="{{ app_url('/') }}" class="nav-link">Library</a>
<a href="{{ app_url('/backgrounds') }}" class="nav-link">Backgrounds</a>
<a href="{{ app_url('/settings') }}" class="nav-link">Settings</a>
<span class="text-[#111111]/20 mx-1">///</span>
<a href="/create" class="btn-primary-neo text-sm py-1.5 px-4">
<a href="{{ app_url('/create') }}" class="btn-primary-neo text-sm py-1.5 px-4">
<i data-lucide="plus" class="w-3.5 h-3.5 inline mr-1"></i>
Create
</a>

@ -9,16 +9,16 @@
<p class="text-[#111111]/50 font-mono text-sm">Configure platform credentials and video generation preferences.</p>
</div>
<div class="flex gap-2">
<button id="defaultSettingsBtn" type="button" class="btn-ghost-neo text-sm">
<button id="defaultSettingsBtn" type="button" class="btn-ghost-neo text-sm" data-demo-disabled>
Reset Defaults
</button>
<button form="settingsForm" type="submit" class="btn-primary-neo text-sm">
<button form="settingsForm" type="submit" class="btn-primary-neo text-sm" data-demo-disabled>
Save Changes
</button>
</div>
</div>
<form id="settingsForm" action="/settings" method="post" novalidate>
<form id="settingsForm" action="{{ app_url('/settings') }}" method="post" novalidate>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Navigation Tabs -->
@ -214,7 +214,7 @@
<option value="{{background}}">{{ background or 'Random' }}</option>
{% endfor %}
</select>
<label class="label"><a href="/backgrounds" target="_blank" class="label-text-alt text-[#DE6C56] hover:text-[#111111] font-mono text-xs">Manage video files &rarr;</a></label>
<label class="label"><a href="{{ app_url('/backgrounds') }}" target="_blank" class="label-text-alt text-[#DE6C56] hover:text-[#111111] font-mono text-xs">Manage video files &rarr;</a></label>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text text-[#111111] font-mono text-xs font-medium">Audio Track</span></label>
@ -406,6 +406,13 @@
const data = {{ data | tojson | safe }};
const validateChecks = {{ checks | tojson | safe }};
const form = document.getElementById('settingsForm');
function lockDemoFields() {
if (!window.PUBLIC_DEMO_MODE) return;
form.querySelectorAll('input, select, textarea').forEach(function(el) {
el.disabled = true;
});
}
lockDemoFields();
// ---- Tab Switching -------------------------------------------------
const tabs = document.querySelectorAll('#settingsTabs a');
@ -462,6 +469,7 @@
section.classList.toggle('hidden', !matches);
section.querySelectorAll('input, select, textarea').forEach(el => el.disabled = !matches);
});
lockDemoFields();
}
function matchesDiscoveryMethod(section, method) {
@ -483,6 +491,7 @@
el.disabled = !isThreads || (!matches && !preserveHidden);
});
});
lockDemoFields();
}
platformSelect.addEventListener('change', applyPlatformVisibility);
@ -511,6 +520,7 @@
el.disabled = !matches;
});
});
lockDemoFields();
}
voiceChoiceSelect.addEventListener('change', applyTtsVisibility);

@ -0,0 +1,29 @@
app = "hongphuc-threads-video-maker"
primary_region = "sin"
[build]
dockerfile = "Dockerfile"
[env]
GUI_HOST = "0.0.0.0"
GUI_PORT = "4000"
GUI_OPEN_BROWSER = "0"
GUI_BROWSER_URL = "https://hongphuc5497.com/threads-video-maker/"
PUBLIC_BASE_PATH = "/threads-video-maker"
PUBLIC_DEMO_MODE = "1"
PUBLIC_ORIGIN_HOST = "hongphuc5497.com"
XDG_CACHE_HOME = "/app/.cache"
CLOAKBROWSER_CACHE_DIR = "/app/.cache/cloakbrowser"
[http_service]
internal_port = 4000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[vm]]
memory = "4gb"
cpu_kind = "shared"
cpus = 2

@ -1,5 +1,6 @@
from unittest.mock import MagicMock, patch
import GUI
from GUI import app
@ -43,3 +44,15 @@ def test_settings_get_uses_template_defaults_for_partial_config():
assert response.status_code == 200
assert '"settings.tts.voice_choice": "Supertonic"' in body
assert 'name="settings.tts.supertonic_voice"' in body
def test_public_demo_mode_blocks_mutating_routes(monkeypatch):
app.testing = True
monkeypatch.setattr(GUI, "PUBLIC_DEMO_MODE", True)
client = app.test_client()
assert client.post("/background/add", data={}).status_code == 403
assert client.post("/background/delete", data={}).status_code == 403
assert client.post("/settings", data={}).status_code == 403
assert client.post("/videos/delete", json={"ids": ["abc"]}).status_code == 403
assert client.post("/create", json={}).status_code == 403

Loading…
Cancel
Save