413 lines
14 KiB
Python
413 lines
14 KiB
Python
|
|
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})
|