from datetime import datetime, timedelta, timezone from functools import wraps import secrets import requests from flask import ( Blueprint, current_app, flash, jsonify, redirect, render_template, request, session, url_for ) from .models import EventLog, Stream, StreamSession, User, db from .services.mailer import send_reset_email, send_welcome_email from .utils import ffprobe_stream, generate_password, generate_token, hls_url, ingest_url_with_query bp = Blueprint('main', __name__) def current_user(): uid = session.get('user_id') if not uid: return None return db.session.get(User, uid) def log_event(stream_id, message, level='info', user_id=None): db.session.add(EventLog(stream_id=stream_id, message=message, level=level, user_id=user_id)) db.session.commit() def login_required(role=None): def deco(fn): @wraps(fn) def wrapper(*args, **kwargs): user = current_user() if not user or not user.active: return redirect(url_for('main.login')) if role and user.role != role: return redirect(url_for('main.index')) return fn(*args, **kwargs) return wrapper return deco def stream_for_current_user_or_403(stream_id): user = current_user() stream = db.session.get(Stream, stream_id) if not stream: return None if user.role == 'admin': return stream if stream.user_id != user.id: return None return stream def media_api_paths(): try: res = requests.get(f"{current_app.config['MEDIAMTX_API_URL'].rstrip('/')}/v3/paths/list", timeout=2) res.raise_for_status() return res.json().get('items', []) except Exception as exc: current_app.logger.warning(f'MediaMTX API unavailable: {exc}') return [] def path_info_for(slug): for item in media_api_paths(): if item.get('name') == slug: return item return None def extract_slug_token_from_auth(data: dict): path = (data.get('path') or '').strip().strip('/') query = (data.get('query') or '').strip() user = (data.get('user') or '').strip() password = (data.get('password') or '').strip() slug = path token = None if '/' in path: slug, token = path.split('/', 1) slug = slug.strip() token = token.strip() if not token and query: from urllib.parse import parse_qs token = parse_qs(query).get('token', [None])[0] if not token and password: token = password if not token and user and user.lower() != 'any': token = user return slug, token def update_live_state(stream: Stream): info = path_info_for(stream.slug) stream.is_live = bool(info and (info.get('ready') or info.get('online'))) db.session.commit() return info def stats_for(stream: Stream): if not stream.is_live: return {} return ffprobe_stream(hls_url(current_app.config['MEDIA_BASE_URL'], stream.slug)) @bp.app_context_processor def inject_globals(): return {'current_user': current_user()} @bp.get('/') def index(): user = current_user() if not user: return redirect(url_for('main.login')) if user.role == 'admin': return redirect(url_for('main.admin_dashboard')) if user.stream: return redirect(url_for('main.streamer_panel', stream_id=user.stream.id)) flash('Din konto har ingen stream knyttet endnu.', 'error') return redirect(url_for('main.logout')) @bp.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') user = User.query.filter_by(username=username).first() if user and user.active and user.check_password(password): session.clear() session['user_id'] = user.id if user.force_password_change: return redirect(url_for('main.force_password_change')) if user.role == 'admin': return redirect(url_for('main.admin_dashboard')) return redirect(url_for('main.index')) flash('Forkert brugernavn eller kodeord.', 'error') return render_template('login.html') @bp.get('/logout') def logout(): session.clear() return redirect(url_for('main.login')) @bp.route('/force-password-change', methods=['GET', 'POST']) @login_required() def force_password_change(): user = current_user() if request.method == 'POST': p1 = request.form.get('password', '') p2 = request.form.get('password_confirm', '') if len(p1) < 10: flash('Kode skal være mindst 10 tegn.', 'error') elif p1 != p2: flash('Koderne matcher ikke.', 'error') else: user.set_password(p1) user.force_password_change = False db.session.commit() flash('Din kode er ændret.', 'success') return redirect(url_for('main.index')) return render_template('force_password_change.html') @bp.route('/forgot-password', methods=['GET', 'POST']) def forgot_password(): if request.method == 'POST': identity = request.form.get('identity', '').strip() user = User.query.filter((User.username == identity) | (User.email == identity)).first() if user: token = secrets.token_urlsafe(32) user.reset_token = token user.reset_token_expires_at = datetime.now(timezone.utc) + timedelta(minutes=current_app.config['RESET_TOKEN_TTL_MINUTES']) db.session.commit() reset_url = url_for('main.reset_password', token=token, _external=True) send_reset_email(user, reset_url) flash('Hvis brugeren findes, er der sendt et reset-link.', 'success') return render_template('forgot_password.html') @bp.route('/reset-password/', methods=['GET', 'POST']) def reset_password(token): user = User.query.filter_by(reset_token=token).first() if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now(timezone.utc): flash('Reset-link er ugyldigt eller udløbet.', 'error') return redirect(url_for('main.login')) if request.method == 'POST': p1 = request.form.get('password', '') p2 = request.form.get('password_confirm', '') if len(p1) < 10: flash('Kode skal være mindst 10 tegn.', 'error') elif p1 != p2: flash('Koderne matcher ikke.', 'error') else: user.set_password(p1) user.force_password_change = False user.reset_token = None user.reset_token_expires_at = None db.session.commit() flash('Din kode er nulstillet. Log ind nu.', 'success') return redirect(url_for('main.login')) return render_template('reset_password.html') @bp.get('/admin/dashboard') @login_required('admin') def admin_dashboard(): users = User.query.order_by(User.created_at.desc()).all() streams = Stream.query.order_by(Stream.created_at.desc()).all() path_map = {item.get('name'): item for item in media_api_paths()} return render_template('admin_dashboard.html', users=users, streams=streams, path_map=path_map) @bp.route('/admin/streamers/new', methods=['GET', 'POST']) @login_required('admin') def create_streamer(): if request.method == 'POST': display_name = request.form.get('display_name', '').strip() username = request.form.get('username', '').strip().lower() email = request.form.get('email', '').strip().lower() stream_name = request.form.get('stream_name', '').strip() slug = request.form.get('slug', '').strip().lower().replace(' ', '-').replace('/', '-') chat_type = request.form.get('chat_type', 'none') chat_url = request.form.get('chat_url', '').strip() notes = request.form.get('notes', '').strip() if not all([display_name, username, email, stream_name, slug]): flash('Alle obligatoriske felter skal udfyldes.', 'error') return render_template('create_streamer.html') if User.query.filter((User.username == username) | (User.email == email)).first(): flash('Brugernavn eller e-mail findes allerede.', 'error') return render_template('create_streamer.html') if Stream.query.filter_by(slug=slug).first(): flash('Slug findes allerede.', 'error') return render_template('create_streamer.html') temp_password = generate_password() user = User( username=username, email=email, display_name=display_name, role='streamer', active=True, force_password_change=True, ) user.set_password(temp_password) db.session.add(user) db.session.flush() stream = Stream( user_id=user.id, name=stream_name, slug=slug, token=generate_token(), chat_type=chat_type, chat_url=chat_url, notes=notes, active=True, ) db.session.add(stream) db.session.commit() send_welcome_email(user, temp_password) log_event(stream.id, 'Streamer og stream oprettet', user_id=current_user().id) flash('Streamer og stream oprettet. Mail er sendt.', 'success') return redirect(url_for('main.stream_detail_admin', stream_id=stream.id)) return render_template('create_streamer.html') @bp.get('/admin/streams/') @login_required('admin') def stream_detail_admin(stream_id): stream = db.session.get(Stream, stream_id) if not stream: return redirect(url_for('main.admin_dashboard')) path_info = update_live_state(stream) stats = stats_for(stream) sessions = StreamSession.query.filter_by(stream_id=stream.id).order_by(StreamSession.started_at.desc()).limit(20).all() logs = EventLog.query.filter_by(stream_id=stream.id).order_by(EventLog.created_at.desc()).limit(50).all() host = current_app.config['PUBLIC_BASE_URL'].replace('https://', '').replace('http://', '').rstrip('/') return render_template( 'stream_detail_admin.html', stream=stream, stats=stats, sessions=sessions, logs=logs, path_info=path_info, public_hls=hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], stream.slug), ingest_full=ingest_url_with_query(host, stream.slug, stream.token), ingest_server=f'rtmp://{host}:1935/{stream.slug}', ingest_key=stream.token, ingest_alt_server=f'rtmp://{host}:1935', ingest_alt_key=f'{stream.slug}/{stream.token}', ) @bp.post('/admin/streams//rotate-token') @login_required('admin') def rotate_stream_token(stream_id): stream = db.session.get(Stream, stream_id) if not stream: return redirect(url_for('main.admin_dashboard')) stream.token = generate_token() db.session.commit() log_event(stream.id, 'Token roteret', 'warning', current_user().id) flash('Token er roteret.', 'success') return redirect(url_for('main.stream_detail_admin', stream_id=stream.id)) @bp.post('/admin/streams//toggle') @login_required('admin') def toggle_stream(stream_id): stream = db.session.get(Stream, stream_id) if not stream: return redirect(url_for('main.admin_dashboard')) stream.active = not stream.active db.session.commit() log_event(stream.id, f"Stream {'aktiveret' if stream.active else 'deaktiveret'}", 'warning', current_user().id) flash('Stream-status er ændret.', 'success') return redirect(url_for('main.stream_detail_admin', stream_id=stream.id)) @bp.post('/admin/users//send-reset') @login_required('admin') def admin_send_reset(user_id): user = db.session.get(User, user_id) if not user: return redirect(url_for('main.admin_dashboard')) token = secrets.token_urlsafe(32) user.reset_token = token user.reset_token_expires_at = datetime.now(timezone.utc) + timedelta(minutes=current_app.config['RESET_TOKEN_TTL_MINUTES']) db.session.commit() reset_url = url_for('main.reset_password', token=token, _external=True) send_reset_email(user, reset_url) flash('Reset-mail sendt.', 'success') return redirect(url_for('main.admin_dashboard')) @bp.get('/streamer/panel/') @login_required() def streamer_panel(stream_id): stream = stream_for_current_user_or_403(stream_id) if not stream: return redirect(url_for('main.index')) path_info = update_live_state(stream) stats = stats_for(stream) sessions = StreamSession.query.filter_by(stream_id=stream.id).order_by(StreamSession.started_at.desc()).limit(10).all() return render_template( 'streamer_panel.html', stream=stream, stats=stats, sessions=sessions, path_info=path_info, public_hls=hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], stream.slug), ) @bp.get('/api/streams//status') @login_required() def stream_status(stream_id): stream = stream_for_current_user_or_403(stream_id) if not stream: return jsonify({'error': 'not found'}), 404 path_info = update_live_state(stream) stats = stats_for(stream) return jsonify({ 'id': stream.id, 'slug': stream.slug, 'is_live': stream.is_live, 'path_info': path_info, 'stats': stats, 'preview_url': hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], stream.slug), 'chat_url': stream.chat_url, }) @bp.route('/api/mediamtx/auth', methods=['POST']) def mediamtx_auth(): data = request.get_json(silent=True) or request.form.to_dict() or {} action = (data.get('action') or '').strip() slug, token = extract_slug_token_from_auth(data) if action == 'read': return 'ok', 200 if action != 'publish': return 'denied', 401 if not slug or not token: return 'denied', 401 stream = Stream.query.filter_by(slug=slug, active=True).first() if not stream or stream.token != token: return 'denied', 401 ip = data.get('ip') session_row = StreamSession(stream_id=stream.id, status='live', publisher_ip=ip, source_type=data.get('protocol'), source_id=data.get('id')) db.session.add(session_row) stream.is_live = True db.session.commit() return 'ok', 200 @bp.get('/healthz') def healthz(): return jsonify({'ok': True})