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

@ -7,6 +7,7 @@ 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,6 +22,7 @@ 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
@ -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():
@ -79,6 +130,8 @@ def backgrounds():
@app.route("/background/add", methods=["POST"]) @app.route("/background/add", methods=["POST"])
def background_add(): def background_add():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
# Get form values # Get form values
youtube_uri = request.form.get("youtube_uri", "").strip() youtube_uri = request.form.get("youtube_uri", "").strip()
filename = request.form.get("filename", "").strip() filename = request.form.get("filename", "").strip()
@ -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:
@ -119,6 +175,8 @@ def settings():
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()
@ -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):
@ -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