v2.1-enterprise

This commit is contained in:
2026-03-23 19:32:57 +01:00
parent b1500e5d87
commit 988a8b143f
35 changed files with 1304 additions and 0 deletions
@@ -0,0 +1,412 @@
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/<token>', 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/<int:stream_id>')
@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/<int:stream_id>/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/<int:stream_id>/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/<int:user_id>/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/<int:stream_id>')
@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/<int:stream_id>/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})