commit c6d8eab720a7f0d10bf3bc292228f295db6cf7a2 Author: thomas Date: Mon Mar 23 19:44:54 2026 +0100 First Upload diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..454fd40 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ + +SECRET_KEY=change-me-super-secret +TZ=Europe/Copenhagen +PUBLIC_BASE_URL=https://rtmp.djgulvbass.dk +MEDIA_BASE_URL=http://mediamtx:8888 +MEDIA_PUBLIC_HLS_BASE=https://rtmp.djgulvbass.dk/hls +MEDIAMTX_API_URL=http://mediamtx:9997 + +POSTGRES_DB=streamhub +POSTGRES_USER=streamhub +POSTGRES_PASSWORD=change-me-db +DATABASE_URL=postgresql+psycopg://streamhub:change-me-db@postgres:5432/streamhub + +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@tuxinet.dk +ADMIN_PASSWORD=change-me-admin + +SMTP_HOST=mail.smtp2go.com +SMTP_PORT=2525 +SMTP_USERNAME=noreplay@tuxinet.dk +SMTP_PASSWORD=change-me-smtp +MAIL_FROM=noreplay@tuxinet.dk +MAIL_ENABLED=true + +RESET_TOKEN_TTL_MINUTES=60 +FORCE_HTTPS_URLS=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..684491a --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ + +# TuxiNet StreamHub v2.1 Enterprise (Clean Build) + +Første enterprise-udgave af StreamHub med: + +- PostgreSQL +- Flask backend +- MediaMTX auth via backend +- Admin + streamer roller +- 1 streamer = 1 stream +- SMTP2GO mailflow +- Force password change ved første login +- Password reset via mail +- Branding med `bg.png`, `logo.png`, `videooffline.png` + +## Status + +Denne første udgave er lavet som en **ren base**, så du kan starte rent uden de gamle v2-filer. + +### Implementeret +- Datamodel låst til PostgreSQL +- Admin-login og streamer-login +- Admin kan oprette streamer + stream i samme flow +- Streamer ser kun egen stream +- MediaMTX auth-route +- Status fra MediaMTX API +- Mail helper til onboarding og reset +- Force password change flow +- Forgot/reset password flow +- Offline player image i UI +- Enterprise login design + +### Delvist scaffoldet / klar til næste iteration +- Action/bot layer (`kick publisher`, `restart service`) er scaffoldet som placeholder +- Mere avancerede live-metrics kan udvides senere +- Fuldt migrationsflow med Alembic kan tilføjes senere + +## Hurtig opstart + +```bash +cp .env.example .env +nano .env + +docker compose build --no-cache +docker compose up -d +``` + +Åbn derefter: +- Web: `http://IP` eller dit reverse proxied domæne +- MediaMTX API: internt på `http://mediamtx:9997` + +## Første admin-bruger + +Backend opretter automatisk admin-brugeren ud fra `.env`: + +- `ADMIN_USERNAME` +- `ADMIN_EMAIL` +- `ADMIN_PASSWORD` + +## SMTP2GO + +Sæt i `.env`: + +```env +SMTP_HOST=mail.smtp2go.com +SMTP_PORT=2525 +SMTP_USERNAME=noreplay@tuxinet.dk +SMTP_PASSWORD=DIN_SMTP2GO_ADGANGSKODE +MAIL_FROM=noreplay@tuxinet.dk +``` + +## MediaMTX ingest-formater + +### OBS +- Server: `rtmp://dit-domæne:1935/` +- Key: `` + +### Larix / Streamlabs fallback +- URL: `rtmp://dit-domæne:1935` +- Key: `/` + +### Query-token +- `rtmp://dit-domæne:1935/?token=` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3b516a3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ + +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "wsgi:app"] diff --git a/backend/__pycache__/wsgi.cpython-313.pyc b/backend/__pycache__/wsgi.cpython-313.pyc new file mode 100644 index 0000000..ee5830d Binary files /dev/null and b/backend/__pycache__/wsgi.cpython-313.pyc differ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..cb85886 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,34 @@ + +import os +from flask import Flask + +from .models import db +from .routes import bp +from .services.bootstrap import bootstrap_system + + +def create_app(): + app = Flask(__name__, instance_relative_config=True) + app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'change-me') + app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['PUBLIC_BASE_URL'] = os.getenv('PUBLIC_BASE_URL', 'http://localhost') + app.config['MEDIA_BASE_URL'] = os.getenv('MEDIA_BASE_URL', 'http://mediamtx:8888') + app.config['MEDIA_PUBLIC_HLS_BASE'] = os.getenv('MEDIA_PUBLIC_HLS_BASE', 'http://localhost/hls') + app.config['MEDIAMTX_API_URL'] = os.getenv('MEDIAMTX_API_URL', 'http://mediamtx:9997') + app.config['SMTP_HOST'] = os.getenv('SMTP_HOST', '') + app.config['SMTP_PORT'] = int(os.getenv('SMTP_PORT', '2525')) + app.config['SMTP_USERNAME'] = os.getenv('SMTP_USERNAME', '') + app.config['SMTP_PASSWORD'] = os.getenv('SMTP_PASSWORD', '') + app.config['MAIL_FROM'] = os.getenv('MAIL_FROM', os.getenv('SMTP_USERNAME', 'noreply@example.com')) + app.config['MAIL_ENABLED'] = os.getenv('MAIL_ENABLED', 'true').lower() == 'true' + app.config['RESET_TOKEN_TTL_MINUTES'] = int(os.getenv('RESET_TOKEN_TTL_MINUTES', '60')) + + db.init_app(app) + app.register_blueprint(bp) + + with app.app_context(): + db.create_all() + bootstrap_system(app) + + return app diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..7a5da89 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..903d5ae Binary files /dev/null and b/backend/app/__pycache__/models.cpython-313.pyc differ diff --git a/backend/app/__pycache__/routes.cpython-313.pyc b/backend/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..2d34f6a Binary files /dev/null and b/backend/app/__pycache__/routes.cpython-313.pyc differ diff --git a/backend/app/__pycache__/utils.cpython-313.pyc b/backend/app/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..1c4bdb0 Binary files /dev/null and b/backend/app/__pycache__/utils.cpython-313.pyc differ diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..1306fa6 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,85 @@ + +from datetime import datetime, timezone +from werkzeug.security import check_password_hash, generate_password_hash +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() + + +def utcnow(): + return datetime.now(timezone.utc) + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(120), unique=True, nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) + display_name = db.Column(db.String(255), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(20), nullable=False, default='streamer') + active = db.Column(db.Boolean, nullable=False, default=True) + force_password_change = db.Column(db.Boolean, nullable=False, default=True) + reset_token = db.Column(db.String(255), nullable=True) + reset_token_expires_at = db.Column(db.DateTime(timezone=True), nullable=True) + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow) + updated_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + stream = db.relationship('Stream', back_populates='user', uselist=False) + + def set_password(self, raw: str): + self.password_hash = generate_password_hash(raw) + + def check_password(self, raw: str) -> bool: + return check_password_hash(self.password_hash, raw) + + +class Stream(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True) + name = db.Column(db.String(255), nullable=False) + slug = db.Column(db.String(120), unique=True, nullable=False) + token = db.Column(db.String(255), nullable=False) + chat_type = db.Column(db.String(30), nullable=False, default='none') + chat_url = db.Column(db.Text, nullable=True) + notes = db.Column(db.Text, nullable=True) + active = db.Column(db.Boolean, nullable=False, default=True) + is_live = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow) + updated_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + user = db.relationship('User', back_populates='stream') + sessions = db.relationship('StreamSession', back_populates='stream', cascade='all, delete-orphan') + logs = db.relationship('EventLog', back_populates='stream', cascade='all, delete-orphan') + + +class StreamSession(db.Model): + id = db.Column(db.Integer, primary_key=True) + stream_id = db.Column(db.Integer, db.ForeignKey('stream.id'), nullable=False) + started_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow) + ended_at = db.Column(db.DateTime(timezone=True), nullable=True) + status = db.Column(db.String(50), nullable=False, default='offline') + source_type = db.Column(db.String(50), nullable=True) + source_id = db.Column(db.String(120), nullable=True) + publisher_ip = db.Column(db.String(64), nullable=True) + video_codec = db.Column(db.String(64), nullable=True) + audio_codec = db.Column(db.String(64), nullable=True) + audio_channels = db.Column(db.String(32), nullable=True) + audio_sample_rate = db.Column(db.String(32), nullable=True) + width = db.Column(db.Integer, nullable=True) + height = db.Column(db.Integer, nullable=True) + fps = db.Column(db.String(32), nullable=True) + bitrate = db.Column(db.String(64), nullable=True) + + stream = db.relationship('Stream', back_populates='sessions') + + +class EventLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + stream_id = db.Column(db.Integer, db.ForeignKey('stream.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + level = db.Column(db.String(20), nullable=False, default='info') + message = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow) + + stream = db.relationship('Stream', back_populates='logs') diff --git a/backend/app/routes.py b/backend/app/routes.py new file mode 100644 index 0000000..14136ce --- /dev/null +++ b/backend/app/routes.py @@ -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/', 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}) diff --git a/backend/app/services/__pycache__/bootstrap.cpython-313.pyc b/backend/app/services/__pycache__/bootstrap.cpython-313.pyc new file mode 100644 index 0000000..e81db2e Binary files /dev/null and b/backend/app/services/__pycache__/bootstrap.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/mailer.cpython-313.pyc b/backend/app/services/__pycache__/mailer.cpython-313.pyc new file mode 100644 index 0000000..7fc4aa9 Binary files /dev/null and b/backend/app/services/__pycache__/mailer.cpython-313.pyc differ diff --git a/backend/app/services/bootstrap.py b/backend/app/services/bootstrap.py new file mode 100644 index 0000000..234690e --- /dev/null +++ b/backend/app/services/bootstrap.py @@ -0,0 +1,28 @@ + +import os +from flask import current_app + +from ..models import User, db + + +def bootstrap_system(app): + admin_username = os.getenv('ADMIN_USERNAME', 'admin').strip() + admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com').strip() + admin_password = os.getenv('ADMIN_PASSWORD', 'change-me-admin').strip() + + existing = User.query.filter_by(username=admin_username).first() + if existing: + return + + admin = User( + username=admin_username, + email=admin_email, + display_name='Administrator', + role='admin', + active=True, + force_password_change=False, + ) + admin.set_password(admin_password) + db.session.add(admin) + db.session.commit() + current_app.logger.info(f'Bootstrap admin created: {admin_username}') diff --git a/backend/app/services/mailer.py b/backend/app/services/mailer.py new file mode 100644 index 0000000..32079bb --- /dev/null +++ b/backend/app/services/mailer.py @@ -0,0 +1,55 @@ +import smtplib +from email.message import EmailMessage +from flask import current_app, url_for + + +def _send_mail(to_email: str, subject: str, body: str): + if not current_app.config['MAIL_ENABLED']: + current_app.logger.info(f"MAIL DISABLED -> To: {to_email} | Subject: {subject}\n{body}") + return + + msg = EmailMessage() + msg['Subject'] = subject + msg['From'] = current_app.config['MAIL_FROM'] + msg['To'] = to_email + msg.set_content(body) + + with smtplib.SMTP(current_app.config['SMTP_HOST'], current_app.config['SMTP_PORT'], timeout=20) as smtp: + smtp.starttls() + smtp.login(current_app.config['SMTP_USERNAME'], current_app.config['SMTP_PASSWORD']) + smtp.send_message(msg) + + +def send_welcome_email(user, temporary_password: str): + login_url = url_for('main.login', _external=True) + body = f"""Hej {user.display_name}, + +Din TuxiNet StreamHub-konto er blevet oprettet. + +Brugernavn: {user.username} +Midlertidig kode: {temporary_password} +Login: {login_url} + +Du skal ændre din kode ved første login. + +Hilsen +TuxiNet StreamHub +""" + _send_mail(user.email, 'Velkommen til TuxiNet StreamHub', body) + + +def send_reset_email(user, reset_url: str): + body = f"""Hej {user.display_name}, + +Du har bedt om at nulstille din adgangskode. + +Reset-link: +{reset_url} + +Linket udløber automatisk. +Hvis du ikke har bedt om dette, kan du ignorere mailen. + +Hilsen +TuxiNet StreamHub +""" + _send_mail(user.email, 'Nulstil din adgangskode', body) diff --git a/backend/app/static/bg.png b/backend/app/static/bg.png new file mode 100644 index 0000000..a09a131 Binary files /dev/null and b/backend/app/static/bg.png differ diff --git a/backend/app/static/logo.png b/backend/app/static/logo.png new file mode 100644 index 0000000..314b550 Binary files /dev/null and b/backend/app/static/logo.png differ diff --git a/backend/app/static/style.css b/backend/app/static/style.css new file mode 100644 index 0000000..f7c56af --- /dev/null +++ b/backend/app/static/style.css @@ -0,0 +1,60 @@ + +:root { + --bg-dark: #08111b; + --bg-panel: rgba(12, 20, 32, 0.88); + --bg-panel-2: rgba(9, 16, 27, 0.95); + --accent: #00d8ff; + --accent-2: #21b8ff; + --text-main: #eef7ff; + --text-soft: #9fb8cc; + --border: rgba(0, 216, 255, 0.18); + --ok: #78f5b0; + --bad: #ff7f96; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: Inter, Arial, sans-serif; background: radial-gradient(circle at top, #0b1c2f, #08111b 55%); color: var(--text-main); } +body.login-page { background: url('/static/bg.png') center center / cover no-repeat fixed; } +a { color: var(--accent); text-decoration: none; } +.container { max-width: 1360px; margin: 0 auto; padding: 2rem; } +.topbar { display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; background: rgba(4,10,18,.9); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:20; } +.topbar nav { display:flex; gap:1rem; } +.brand { display:flex; align-items:center; gap:.75rem; color:var(--text-main); font-weight:700; } +.brand img { height:38px; } +.card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: 20px; padding: 1.2rem; box-shadow: 0 0 40px rgba(0,0,0,.18); } +.narrow { max-width: 520px; margin: 2rem auto; } +.page-header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1.25rem; } +.button, button { border:0; border-radius:14px; padding:.85rem 1.15rem; cursor:pointer; font-weight:700; background: linear-gradient(135deg, var(--accent), var(--accent-2)); color:#03111a; } +.button.secondary, button.secondary { background: rgba(255,255,255,.06); color: var(--text-main); border:1px solid var(--border); } +.form-grid { display:grid; gap:1rem; } +label { display:grid; gap:.35rem; color:var(--text-soft); } +input, textarea, select { width:100%; padding:.9rem 1rem; border-radius:14px; border:1px solid var(--border); background: rgba(0,0,0,.25); color:var(--text-main); } +textarea { min-height: 120px; } +.flash { padding: .9rem 1rem; border-radius: 12px; margin-bottom: 1rem; } +.flash.success { background:#173726; color:#a9f3c7; } +.flash.error { background:#3c1920; color:#ffc1cf; } +.login-wrap { min-height: calc(100vh - 96px); display:grid; place-items:center; } +.login-card { width:min(480px, 92vw); padding:2rem; border-radius:26px; background: rgba(8, 17, 27, .74); border:1px solid var(--border); backdrop-filter: blur(14px); } +.login-logo { width: 100%; max-width: 240px; display:block; margin:0 auto 1rem; } +.login-links { margin-top: 1rem; text-align:center; } +.muted { color:var(--text-soft); } +.stream-table { width:100%; border-collapse: collapse; } +.stream-table th, .stream-table td { padding:.85rem .75rem; border-bottom:1px solid rgba(255,255,255,.08); text-align:left; vertical-align:middle; } +.space-top { margin-top: 1rem; } +.detail-layout { display:grid; grid-template-columns:1.45fr .75fr; gap:1rem; } +.lower-gap { margin-top: 1rem; } +.streamer-layout-fixed { display:grid; grid-template-columns:1.45fr .75fr; gap:1rem; } +.panel-head { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1rem; } +.video-frame { background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08); border-radius:16px; padding:.5rem; overflow:hidden; } +.video-frame video { width:100%; display:block; border-radius:12px; min-height:320px; background:#000; } +.video-frame.large video { min-height:430px; } +.stats-grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.75rem; } +.compact-top { margin-top:1rem; } +.mini-stat { background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.06); border-radius:14px; padding:.8rem; } +.mini-stat span { display:block; opacity:.72; font-size:.85rem; margin-bottom:.2rem; } +.badge { display:inline-flex; align-items:center; justify-content:center; padding:.35rem .75rem; border-radius:999px; font-weight:700; } +.badge.live { background:#183d27; color:var(--ok); } +.badge.offline { background:#3c1d24; color:var(--bad); } +.stack-actions { display:grid; gap:.75rem; margin-top:1rem; } +.chat-frame { width:100%; min-height:720px; border:none; border-radius:12px; background:#0b0f16; } +@media (max-width:1100px) { .detail-layout, .streamer-layout-fixed { grid-template-columns:1fr; } .stats-grid { grid-template-columns:1fr 1fr; } } +@media (max-width:700px) { .container { padding: 1rem; } .page-header, .topbar { display:grid; grid-template-columns:1fr; } .stats-grid { grid-template-columns:1fr; } } diff --git a/backend/app/static/videooffline.png b/backend/app/static/videooffline.png new file mode 100644 index 0000000..bd6bf7f Binary files /dev/null and b/backend/app/static/videooffline.png differ diff --git a/backend/app/templates/admin_dashboard.html b/backend/app/templates/admin_dashboard.html new file mode 100644 index 0000000..7e5296c --- /dev/null +++ b/backend/app/templates/admin_dashboard.html @@ -0,0 +1,56 @@ + +{% extends 'base.html' %} +{% block title %}Admin Dashboard{% endblock %} +{% block content %} + +
+

Streams

+ + + + {% for s in streams %} + {% set p = path_map.get(s.slug) %} + {% set live = p and (p.ready or p.online) %} + + + + + + + + + {% endfor %} + +
NavnSlugEjerStatusAktivHandlinger
{{ s.name }}/{{ s.slug }}{{ s.user.display_name }}{{ 'LIVE' if live else 'OFFLINE' }}{{ 'Ja' if s.active else 'Nej' }}Detaljer
+
+
+

Brugere

+ + + + {% for u in users %} + + + + + + + + + {% endfor %} + +
NavnBrugernavnE-mailRolleStatusHandlinger
{{ u.display_name }}{{ u.username }}{{ u.email }}{{ u.role }}{{ 'Aktiv' if u.active else 'Deaktiv' }} + {% if u.role == 'streamer' %} +
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/backend/app/templates/base.html b/backend/app/templates/base.html new file mode 100644 index 0000000..45fa1a8 --- /dev/null +++ b/backend/app/templates/base.html @@ -0,0 +1,38 @@ + + + + + + + {% block title %}TuxiNet StreamHub{% endblock %} + + + + +
+ + TuxiNet StreamHub + TuxiNet StreamHub + + {% if current_user %} + + {% endif %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/backend/app/templates/create_streamer.html b/backend/app/templates/create_streamer.html new file mode 100644 index 0000000..2746532 --- /dev/null +++ b/backend/app/templates/create_streamer.html @@ -0,0 +1,29 @@ + +{% extends 'base.html' %} +{% block title %}Ny streamer{% endblock %} +{% block content %} + +
+
+

Streamer

+
+ + + +

Stream

+ + + + + + +
+
+
+{% endblock %} diff --git a/backend/app/templates/force_password_change.html b/backend/app/templates/force_password_change.html new file mode 100644 index 0000000..9d211ea --- /dev/null +++ b/backend/app/templates/force_password_change.html @@ -0,0 +1,14 @@ + +{% extends 'base.html' %} +{% block title %}Skift kode{% endblock %} +{% block content %} +
+

Skift din kode

+

Du skal ændre din midlertidige kode, før du kan fortsætte.

+
+ + + +
+
+{% endblock %} diff --git a/backend/app/templates/forgot_password.html b/backend/app/templates/forgot_password.html new file mode 100644 index 0000000..62996dd --- /dev/null +++ b/backend/app/templates/forgot_password.html @@ -0,0 +1,12 @@ + +{% extends 'base.html' %} +{% block title %}Glemt kodeord{% endblock %} +{% block content %} +
+

Glemt kodeord

+
+ + +
+
+{% endblock %} diff --git a/backend/app/templates/login.html b/backend/app/templates/login.html new file mode 100644 index 0000000..c5cd7fa --- /dev/null +++ b/backend/app/templates/login.html @@ -0,0 +1,19 @@ + +{% extends 'base.html' %} +{% block title %}Login{% endblock %} +{% block body_class %}login-page{% endblock %} +{% block content %} + +{% endblock %} diff --git a/backend/app/templates/reset_password.html b/backend/app/templates/reset_password.html new file mode 100644 index 0000000..8b26666 --- /dev/null +++ b/backend/app/templates/reset_password.html @@ -0,0 +1,13 @@ + +{% extends 'base.html' %} +{% block title %}Reset kodeord{% endblock %} +{% block content %} +
+

Nulstil kodeord

+
+ + + +
+
+{% endblock %} diff --git a/backend/app/templates/stream_detail_admin.html b/backend/app/templates/stream_detail_admin.html new file mode 100644 index 0000000..7b00f5a --- /dev/null +++ b/backend/app/templates/stream_detail_admin.html @@ -0,0 +1,71 @@ + +{% extends 'base.html' %} +{% block title %}{{ stream.name }}{% endblock %} +{% block content %} + +
+
+

Preview

+
+
{{ public_hls }}
+
+
Bitrate{{ stats.get('bitrate', '-') }}
+
FPS{{ stats.get('fps', '-') }}
+
Video{{ stats.get('video_codec', '-') }}
+
Lyd{{ stats.get('audio_codec', '-') }}
+
Kanaler{{ stats.get('audio_channels', '-') }}
+
Sample rate{{ stats.get('audio_sample_rate', '-') }}
+
+
+ +
+
+
+

Forbindelser / sessioner

+ + + + {% for row in sessions %} + + {% endfor %} + +
StartStatusIPKilde
{{ row.started_at }}{{ row.status }}{{ row.publisher_ip or '-' }}{{ row.source_type or '-' }}
+
+ +
+ +{% endblock %} diff --git a/backend/app/templates/streamer_panel.html b/backend/app/templates/streamer_panel.html new file mode 100644 index 0000000..9c3b638 --- /dev/null +++ b/backend/app/templates/streamer_panel.html @@ -0,0 +1,85 @@ + +{% extends 'base.html' %} +{% block title %}Streamer Panel{% endblock %} +{% block content %} + +
+
+

Preview

{{ public_hls }}
+
+
+
Bitrate{{ stats.get('bitrate', '-') }}
+
FPS{{ stats.get('fps', '-') }}
+
Video{{ stats.get('video_codec', '-') }}
+
Lyd{{ stats.get('audio_codec', '-') }}
+
Kanaler{{ stats.get('audio_channels', '-') }}
+
Sample rate{{ stats.get('audio_sample_rate', '-') }}
+
Ready{{ 'true' if path_info and path_info.ready else 'false' }}
+
Online{{ 'true' if path_info and path_info.online else 'false' }}
+
+
+

Dine forbindelser

+ + + + {% for row in sessions %} + + {% endfor %} + +
StartStatusIP
{{ row.started_at }}{{ row.status }}{{ row.publisher_ip or '-' }}
+
+
+ +
+ +{% endblock %} diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..d1d7023 --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,50 @@ + +import secrets +import subprocess +from urllib.parse import urlencode + + +def generate_token(length: int = 32) -> str: + return secrets.token_urlsafe(length)[:length] + + +def generate_password(length: int = 14) -> str: + alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%' + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def hls_url(base: str, slug: str) -> str: + return f"{base.rstrip('/')}/{slug}/index.m3u8" + + +def ingest_url_with_query(host: str, slug: str, token: str) -> str: + return f"rtmp://{host}:1935/{slug}?{urlencode({'token': token})}" + + +def ffprobe_stream(url: str) -> dict: + cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', url] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=8, check=True) + except Exception: + return {} + + import json + try: + payload = json.loads(result.stdout or '{}') + except Exception: + return {} + + out = {} + for stream in payload.get('streams', []): + if stream.get('codec_type') == 'video': + out['video_codec'] = stream.get('codec_name') + out['width'] = stream.get('width') + out['height'] = stream.get('height') + out['fps'] = stream.get('avg_frame_rate') + elif stream.get('codec_type') == 'audio': + out['audio_codec'] = stream.get('codec_name') + out['audio_channels'] = stream.get('channels') + out['audio_sample_rate'] = stream.get('sample_rate') + if payload.get('format', {}).get('bit_rate'): + out['bitrate'] = payload['format']['bit_rate'] + return out diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ceeab9c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ + +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.7 +gunicorn==23.0.0 +psycopg[binary]==3.2.1 +requests==2.32.3 +Werkzeug==3.0.3 +itsdangerous==2.2.0 diff --git a/backend/wsgi.py b/backend/wsgi.py new file mode 100644 index 0000000..0a23b5a --- /dev/null +++ b/backend/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app() diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..4337c99 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,3 @@ +:80 { + reverse_proxy backend:5000 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..713e673 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ + +services: + postgres: + image: postgres:16 + container_name: streamhub-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + TZ: ${TZ} + volumes: + - ./data/postgres:/var/lib/postgresql/data + + mediamtx: + image: bluenviron/mediamtx:1.17.0 + container_name: streamhub-mediamtx + restart: unless-stopped + ports: + - "1935:1935" + - "8888:8888" + - "8889:8889" + - "8189:8189/udp" + - "127.0.0.1:9997:9997" + - "127.0.0.1:9998:9998" + volumes: + - ./mediamtx/mediamtx.yml:/mediamtx.yml:ro + + backend: + build: ./backend + container_name: streamhub-backend + restart: unless-stopped + env_file: + - .env + depends_on: + - postgres + - mediamtx + volumes: + - ./data/logs:/app/logs + + caddy: + image: caddy:2.8 + container_name: streamhub-caddy + restart: unless-stopped + depends_on: + - backend + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./data/caddy:/data + - ./data/caddy_config:/config diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml new file mode 100644 index 0000000..0a3bf72 --- /dev/null +++ b/mediamtx/mediamtx.yml @@ -0,0 +1,47 @@ + +logLevel: info +logDestinations: [stdout] + +readTimeout: 10s +writeTimeout: 10s +writeQueueSize: 1024 +udpMaxPayloadSize: 1452 + +api: true +apiAddress: :9997 +metrics: true +metricsAddress: :9998 + +rtmp: true +rtmpAddress: :1935 + +hls: true +hlsAddress: :8888 +hlsAlwaysRemux: true +hlsVariant: lowLatency +hlsSegmentCount: 7 +hlsSegmentDuration: 1s +hlsPartDuration: 250ms +hlsMuxerCloseAfter: 30s + +webrtc: true +webrtcAddress: :8889 +webrtcLocalUDPAddress: :8189 +webrtcIPsFromInterfaces: true + +srt: true +srtAddress: :8890 + +authMethod: http +authHTTPAddress: http://backend:5000/api/mediamtx/auth +authHTTPExclude: + - action: api + - action: metrics + - action: pprof + +pathDefaults: + source: publisher + overridePublisher: true + +paths: + all_others: diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..5bb1314 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cp -n .env.example .env || true +echo "Redigér .env før første start"