Files
Video-Player-Controller/app.py
T
Media ec826bb8f3 fix: restrict web ingest to official YouTube hosts and bound submission size
The submit form's URL validation accepted any http/https host. Restrict it
to an explicit allowlist of official YouTube hosts using normalized,
exact hostname comparison (no substring/suffix matching), reject embedded
userinfo and malformed URLs, and add server-side length bounds for the URL,
submitter, note, and total request body.
2026-08-07 17:04:05 -05:00

388 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import logging
import os
from urllib.parse import urlsplit
from flask import Flask, flash, jsonify, redirect, render_template_string, request, url_for
from controller.config import ControllerConfig, load_config
from controller.models import StoreError
from controller.service import ControllerService, ControllerUnavailableError
LOG = logging.getLogger(__name__)
HTML_FORM = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>YouTube Playlist Submit</title>
<!-- Make it behave on mobile -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
:root {
color-scheme: dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: #020617;
color: #e5e7eb;
}
.page-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.card {
width: 100%;
max-width: 520px;
background: rgba(15, 23, 42, 0.98);
padding: 1.75rem 1.5rem 1.4rem;
border-radius: 1rem;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
}
h2 {
margin: 0 0 0.4rem;
font-size: 1.35rem;
}
.subtitle {
margin: 0 0 1.1rem;
font-size: 0.95rem;
color: #9ca3af;
}
.flash {
margin-bottom: 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 0.6rem;
background: rgba(30, 64, 175, 0.35);
border: 1px solid rgba(129, 140, 248, 0.7);
font-size: 0.9rem;
}
.form-group {
margin-bottom: 0.85rem;
}
label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.92rem;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
padding: 0.7rem 0.85rem;
border-radius: 0.65rem;
border: 1px solid #4b5563;
background: #020617;
color: #e5e7eb;
font-size: 1rem;
}
input::placeholder,
textarea::placeholder {
color: #6b7280;
}
input:focus,
textarea:focus {
outline: 2px solid #38bdf8;
outline-offset: 2px;
border-color: #38bdf8;
}
textarea {
min-height: 3.2rem;
resize: vertical;
}
button {
width: 100%;
padding: 0.8rem 1.5rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: #fff;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.2rem;
}
button:active {
transform: scale(0.98);
}
.helper {
margin-top: 0.9rem;
font-size: 0.8rem;
color: #9ca3af;
}
@media (max-width: 480px) {
.page-wrapper {
padding: 1rem;
}
.card {
padding: 1.4rem 1.1rem 1.2rem;
border-radius: 0.9rem;
}
h2 {
font-size: 1.2rem;
}
input,
textarea {
font-size: 1.02rem;
}
button {
font-size: 1.02rem;
}
}
</style>
</head>
<body>
<div class="page-wrapper">
<div class="card">
<h2>Submit YouTube Playlist</h2>
<p class="subtitle">
Paste a playlist link below. It’ll be queued for download and assigned to the next free slot.
</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for m in messages %}
<div class="flash">{{ m }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('submit') }}">
<div class="form-group">
<label for="url">YouTube URL</label>
<input
type="url"
id="url"
name="url"
required
placeholder="https://www.youtube.com/playlist?list=..."
>
</div>
<div class="form-group">
<label for="submitter">Your name (optional)</label>
<input
type="text"
id="submitter"
name="submitter"
placeholder="Simon Peter"
>
</div>
<div class="form-group">
<label for="note">Note (optional)</label>
<textarea
id="note"
name="note"
rows="3"
placeholder="Service, event, etc."
></textarea>
</div>
<button type="submit">Submit Playlist</button>
</form>
<p class="helper">
Playlists are kept for roughly 48 hours after they’re downloaded.
</p>
</div>
</div>
</body>
</html>
"""
ALLOWED_YOUTUBE_HOSTS = frozenset(
{
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
}
)
MAX_URL_LENGTH = 2048
MAX_SUBMITTER_LENGTH = 200
MAX_NOTE_LENGTH = 1000
MAX_REQUEST_CONTENT_LENGTH = 32 * 1024
def _valid_media_url(value: str) -> bool:
if not value or len(value) > MAX_URL_LENGTH:
return False
try:
parsed = urlsplit(value)
hostname = parsed.hostname
has_userinfo = parsed.username is not None or parsed.password is not None
except ValueError:
return False
if parsed.scheme.lower() not in {"http", "https"}:
return False
if has_userinfo or not hostname:
return False
return hostname.lower() in ALLOWED_YOUTUBE_HOSTS
def _submission_message(result: dict[str, object]) -> str:
status = result.get("status")
if status == "queue_full":
seconds = max(0, int(result.get("oldest_expires_in_seconds") or 0))
hours = max(1, (seconds + 3599) // 3600)
return f"Queue is full. Oldest playlist expires in about {hours} hour(s)."
if status == "slot_busy":
return f"Slot {result.get('slot')} is already being downloaded."
if status == "slot_protected":
return (
f"Slot {result.get('slot')} is protected by active or uncertain playback; "
"the playlist was not replaced."
)
if status == "shutting_down":
return "The Controller is shutting down; please try again shortly."
if status == "worker_error":
return "The playlist was reserved, but its download worker could not start."
if status != "queued":
return "The Controller could not queue that playlist; please try again."
slot = result.get("slot")
message = f"Playlist queued in slot {slot}."
if result.get("reused_slot"):
message += " (Existing slot – only new videos will be added.)"
else:
message += " It will start downloading shortly."
return message
def create_app(
config: ControllerConfig,
service: ControllerService,
*,
testing: bool = False,
) -> Flask:
"""Create the HTTP surface without starting MQTT, threads, or persistence."""
secret_key = os.environ.get("FLASK_SECRET_KEY")
if not secret_key and not testing:
raise RuntimeError("FLASK_SECRET_KEY must be set for the Controller web service")
web_app = Flask(__name__)
web_app.config.update(
TESTING=testing,
SECRET_KEY=secret_key or "controller-test-only-secret",
MAX_CONTENT_LENGTH=MAX_REQUEST_CONTENT_LENGTH,
)
web_app.extensions["controller_service"] = service
web_app.extensions["controller_config"] = config
@web_app.get("/")
def index():
return render_template_string(HTML_FORM)
@web_app.errorhandler(413)
def _submission_too_large(_error):
flash("The submission was too large.")
return redirect(url_for("index"))
@web_app.post("/submit")
def submit():
url = request.form.get("url", "").strip()
submitter = request.form.get("submitter", "").strip() or None
note = request.form.get("note", "").strip() or None
if not _valid_media_url(url):
flash(
"Invalid URL. Only YouTube links are accepted "
"(e.g. https://www.youtube.com/... or https://youtu.be/...)."
)
return redirect(url_for("index"))
if submitter is not None and len(submitter) > MAX_SUBMITTER_LENGTH:
flash(f"Your name must be {MAX_SUBMITTER_LENGTH} characters or fewer.")
return redirect(url_for("index"))
if note is not None and len(note) > MAX_NOTE_LENGTH:
flash(f"The note must be {MAX_NOTE_LENGTH} characters or fewer.")
return redirect(url_for("index"))
try:
result = service.enqueue_download(url, submitter, note)
except ControllerUnavailableError as exc:
LOG.warning("playlist submission was rejected: %s", exc)
flash("The Controller is unavailable; please try again shortly.")
except StoreError as exc:
LOG.error("playlist submission hit a storage error: %s", exc)
flash("Controller storage is unavailable; the playlist was not queued.")
except ValueError as exc:
LOG.warning("playlist submission was rejected: %s", exc)
flash(f"The playlist request was rejected: {exc}")
except Exception:
LOG.exception("playlist submission failed unexpectedly")
flash("The Controller could not queue that playlist; please try again.")
else:
flash(_submission_message(result))
return redirect(url_for("index"))
@web_app.get("/healthz")
def healthz():
try:
health = service.health()
except Exception as exc:
LOG.exception("Controller health check failed")
return jsonify(
{
"status": "unavailable",
"started": False,
"coordinator_alive": False,
"error": str(exc),
}
), 503
serving = bool(health.get("started") and health.get("coordinator_alive"))
return jsonify(health), 200 if serving else 503
return web_app
def _run_development_server() -> None:
config = load_config()
service = ControllerService(config)
web_app = create_app(config, service)
service.start()
try:
web_app.run(host=config.web.host, port=config.web.port)
finally:
service.stop()
if __name__ == "__main__":
_run_development_server()