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 3 months ago
parent 1186efbe4d
commit f97b0cc881

108
GUI.py

@ -5,8 +5,9 @@ import sys
import threading import threading
import time import time
import webbrowser import webbrowser
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
# Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump" # Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump"
import tomlkit import tomlkit
@ -21,10 +22,11 @@ from flask import (
send_from_directory, send_from_directory,
url_for, url_for,
) )
from werkzeug.wrappers import Response
import utils.gui_utils as gui import utils.gui_utils as gui
from utils.docker_bootstrap import ensure_runtime_state 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() 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")) PORT = int(os.environ.get("GUI_PORT", "4000"))
OPEN_BROWSER = os.environ.get("GUI_OPEN_BROWSER", "1").lower() in {"1", "true", "yes", "on"} 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}") 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 # Configure application
app = Flask(__name__, template_folder="GUI") 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) 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 # Ensure responses aren't cached + security headers
@app.after_request @app.after_request
def after_request(response): def after_request(response):
@ -58,14 +100,23 @@ def csrf_check():
if request.method in ("POST", "PUT", "PATCH", "DELETE"): if request.method in ("POST", "PUT", "PATCH", "DELETE"):
origin = request.headers.get("Origin") origin = request.headers.get("Origin")
if origin: if origin:
# Allow same-origin only (localhost dev ports) # Allow same-origin + public proxy origin (e.g. Vercel rewrites)
from urllib.parse import urlparse
origin_host = urlparse(origin).hostname origin_host = urlparse(origin).hostname
request_host = urlparse(request.host_url).hostname allowed = {
if origin_host not in (request_host, "localhost", "127.0.0.1"): 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 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 # Display index.html
@app.route("/") @app.route("/")
def index(): def index():
@ -77,13 +128,15 @@ def backgrounds():
return render_template("backgrounds.html", file="backgrounds.json") return render_template("backgrounds.html", file="backgrounds.json")
@app.route("/background/add", methods=["POST"]) @app.route("/background/add", methods=["POST"])
def background_add(): def background_add():
# Get form values if PUBLIC_DEMO_MODE:
youtube_uri = request.form.get("youtube_uri", "").strip() return public_demo_forbidden()
filename = request.form.get("filename", "").strip() # Get form values
citation = request.form.get("citation", "").strip() youtube_uri = request.form.get("youtube_uri", "").strip()
position = request.form.get("position", "").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) gui.add_background(youtube_uri, filename, citation, position)
@ -92,6 +145,8 @@ def background_add():
@app.route("/background/delete", methods=["POST"]) @app.route("/background/delete", methods=["POST"])
def background_delete(): def background_delete():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
key = request.form.get("background-key") key = request.form.get("background-key")
gui.delete_background(key) gui.delete_background(key)
@ -99,7 +154,8 @@ def background_delete():
_SENSITIVE_KEYS = {"password", "client_secret", "access_token", "2fa_secret", _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: def _redact_secrets(data: dict) -> dict:
@ -113,18 +169,20 @@ def _redact_secrets(data: dict) -> dict:
@app.route("/settings", methods=["GET", "POST"]) @app.route("/settings", methods=["GET", "POST"])
def settings(): def settings():
config_load = tomlkit.loads(Path("config.toml").read_text()) 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 # Get checks for all values
checks = gui.get_checks() checks = gui.get_checks()
if request.method == "POST": if request.method == "POST":
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
# Get data from form as dict # Get data from form as dict
data = request.form.to_dict() data = request.form.to_dict()
# Change settings # Change settings
gui.modify_settings(data, config_load, checks) gui.modify_settings(data, config_load, checks)
config = gui.get_config(apply_template_defaults(deepcopy(config_load))) config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
return render_template("settings.html", file="config.toml", data=_redact_secrets(config), checks=checks) 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 # Delete one or more videos by ID
@app.route("/videos/delete", methods=["POST"]) @app.route("/videos/delete", methods=["POST"])
def video_delete(): def video_delete():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
ids = data.get("ids", []) ids = data.get("ids", [])
if not ids or not isinstance(ids, list): if not ids or not isinstance(ids, list):
@ -243,8 +303,8 @@ def _run_pipeline(search_queries=None):
pipeline_state["scraper_events"] = [] pipeline_state["scraper_events"] = []
try: try:
# Load config and merge template defaults for non-interactive GUI runs. # Load config and merge template defaults for non-interactive GUI runs.
settings.config = settings.apply_template_defaults(toml.load("config.toml")) settings.config = settings.apply_template_defaults(toml.load("config.toml"))
# Apply search_queries override if provided from UI # Apply search_queries override if provided from UI
if search_queries: if search_queries:
@ -275,14 +335,14 @@ def _run_pipeline(search_queries=None):
import importlib import importlib
import video_creation.final_video import video_creation.final_video
import video_creation.background import video_creation.background
import video_creation.voices import video_creation.voices
import TTS.engine_wrapper import TTS.engine_wrapper
import platforms.threads.screenshot import platforms.threads.screenshot
import main import main
importlib.reload(video_creation.final_video) importlib.reload(video_creation.final_video)
importlib.reload(video_creation.background) importlib.reload(video_creation.background)
importlib.reload(TTS.engine_wrapper) importlib.reload(TTS.engine_wrapper)
importlib.reload(video_creation.voices) importlib.reload(video_creation.voices)
importlib.reload(platforms.threads.screenshot) importlib.reload(platforms.threads.screenshot)
importlib.reload(main) importlib.reload(main)
@ -306,6 +366,8 @@ def _run_pipeline(search_queries=None):
@app.route("/create", methods=["GET", "POST"]) @app.route("/create", methods=["GET", "POST"])
def create(): def create():
if request.method == "POST": if request.method == "POST":
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
if pipeline_state["running"]: if pipeline_state["running"]:
return jsonify({"status": "already_running"}) return jsonify({"status": "already_running"})
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}

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

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

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

@ -42,6 +42,20 @@
<!-- Lucide icons --> <!-- Lucide icons -->
<script src="https://unpkg.com/lucide@latest"></script> <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> <style>
/* ── Neo-Brutalist Global Overrides ───────────────────────────── */ /* ── Neo-Brutalist Global Overrides ───────────────────────────── */
@ -314,17 +328,17 @@
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<div class="flex items-center justify-between h-14"> <div class="flex items-center justify-between h-14">
<!-- Logo --> <!-- 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> <span class="font-display text-lg text-[#111111] tracking-tighter uppercase">VideoMakerBot</span>
</a> </a>
<!-- Nav Items --> <!-- Nav Items -->
<div class="flex items-center gap-0"> <div class="flex items-center gap-0">
<a href="/" class="nav-link">Library</a> <a href="{{ app_url('/') }}" class="nav-link">Library</a>
<a href="/backgrounds" class="nav-link">Backgrounds</a> <a href="{{ app_url('/backgrounds') }}" class="nav-link">Backgrounds</a>
<a href="/settings" class="nav-link">Settings</a> <a href="{{ app_url('/settings') }}" class="nav-link">Settings</a>
<span class="text-[#111111]/20 mx-1">///</span> <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> <i data-lucide="plus" class="w-3.5 h-3.5 inline mr-1"></i>
Create Create
</a> </a>

@ -9,16 +9,16 @@
<p class="text-[#111111]/50 font-mono text-sm">Configure platform credentials and video generation preferences.</p> <p class="text-[#111111]/50 font-mono text-sm">Configure platform credentials and video generation preferences.</p>
</div> </div>
<div class="flex gap-2"> <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 Reset Defaults
</button> </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 Save Changes
</button> </button>
</div> </div>
</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"> <div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
@ -214,7 +214,7 @@
<option value="{{background}}">{{ background or 'Random' }}</option> <option value="{{background}}">{{ background or 'Random' }}</option>
{% endfor %} {% endfor %}
</select> </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>
<div class="form-control w-full"> <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> <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 data = {{ data | tojson | safe }};
const validateChecks = {{ checks | tojson | safe }}; const validateChecks = {{ checks | tojson | safe }};
const form = document.getElementById('settingsForm'); 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 ------------------------------------------------- // ---- Tab Switching -------------------------------------------------
const tabs = document.querySelectorAll('#settingsTabs a'); const tabs = document.querySelectorAll('#settingsTabs a');
@ -462,6 +469,7 @@
section.classList.toggle('hidden', !matches); section.classList.toggle('hidden', !matches);
section.querySelectorAll('input, select, textarea').forEach(el => el.disabled = !matches); section.querySelectorAll('input, select, textarea').forEach(el => el.disabled = !matches);
}); });
lockDemoFields();
} }
function matchesDiscoveryMethod(section, method) { function matchesDiscoveryMethod(section, method) {
@ -483,6 +491,7 @@
el.disabled = !isThreads || (!matches && !preserveHidden); el.disabled = !isThreads || (!matches && !preserveHidden);
}); });
}); });
lockDemoFields();
} }
platformSelect.addEventListener('change', applyPlatformVisibility); platformSelect.addEventListener('change', applyPlatformVisibility);
@ -511,6 +520,7 @@
el.disabled = !matches; el.disabled = !matches;
}); });
}); });
lockDemoFields();
} }
voiceChoiceSelect.addEventListener('change', applyTtsVisibility); 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 from unittest.mock import MagicMock, patch
import GUI
from GUI import app from GUI import app
@ -43,3 +44,15 @@ def test_settings_get_uses_template_defaults_for_partial_config():
assert response.status_code == 200 assert response.status_code == 200
assert '"settings.tts.voice_choice": "Supertonic"' in body assert '"settings.tts.voice_choice": "Supertonic"' in body
assert 'name="settings.tts.supertonic_voice"' 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