initial commit

This commit is contained in:
2025-03-31 14:55:32 +02:00
commit 9decf702e3
27 changed files with 3235 additions and 0 deletions

0
home_stream/__init__.py Normal file
View File

174
home_stream/app.py Normal file
View File

@@ -0,0 +1,174 @@
# SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
#
# SPDX-License-Identifier: GPL-3.0-only
"""Home Stream Web Application"""
import argparse
import os
from flask import (
Flask,
abort,
redirect,
render_template,
request,
send_file,
session,
url_for,
)
from flask_httpauth import HTTPBasicAuth
from home_stream.helpers import (
file_type,
get_stream_token,
load_config,
secure_path,
serve_via_gunicorn,
validate_user,
verify_password,
)
auth = HTTPBasicAuth()
def create_app(config_path: str) -> Flask:
"""Create a Flask application instance."""
app = Flask(__name__)
load_config(app, config_path)
init_routes(app)
return app
def init_routes(app: Flask):
"""Initialize routes for the Flask application."""
auth.verify_password(verify_password)
@app.context_processor
def inject_auth():
return {
"auth_password": getattr(request, "password", ""),
"stream_token": get_stream_token(session["username"]) if "username" in session else "",
}
def is_authenticated():
return session.get("username") in app.config["USERS"]
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if validate_user(username, password):
session["username"] = username
return redirect(request.args.get("next") or url_for("index"))
error = "Invalid credentials"
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/")
def index():
if not is_authenticated():
return redirect(url_for("login", next=request.full_path))
return redirect(url_for("browse", subpath=""))
@app.route("/browse/", defaults={"subpath": ""})
@app.route("/browse/<path:subpath>")
def browse(subpath):
if not is_authenticated():
return redirect(url_for("login", next=request.full_path))
current_path = secure_path(subpath)
if not os.path.isdir(current_path):
abort(404)
folders, files = [], []
for entry in os.listdir(current_path):
full = os.path.join(current_path, entry)
rel = os.path.join(subpath, entry)
if os.path.isdir(full) and not entry.startswith("."):
folders.append((entry, rel))
elif os.path.isfile(full):
ext = os.path.splitext(entry)[1].lower().strip(".")
if ext in app.config["MEDIA_EXTENSIONS"]:
files.append((entry, rel))
folders.sort(key=lambda x: x[0].lower())
files.sort(key=lambda x: x[0].lower())
return render_template(
"browse.html",
path=subpath,
folders=folders,
files=files,
username=session.get("username"),
)
@app.route("/play/<path:filepath>")
def play(filepath):
if not is_authenticated():
return redirect(url_for("login", next=request.full_path))
secure_path(filepath)
return render_template(
"play.html",
path=filepath,
mediatype=file_type(filepath),
username=session.get("username"),
)
@app.route("/dl/<path:filepath>")
def download(filepath):
if not is_authenticated():
return redirect(url_for("login", next=request.full_path))
full_path = secure_path(filepath)
if os.path.isfile(full_path):
return send_file(full_path)
abort(404)
@app.route("/dl-token/<username>/<token>/<path:filepath>")
def download_token_auth(username, token, filepath):
expected = get_stream_token(username)
if token != expected:
abort(403)
full_path = secure_path(filepath)
if os.path.isfile(full_path):
return send_file(full_path)
abort(404)
def main():
"""Main entry point for the application."""
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"-c", "--config-file", required=True, help="Path to the app's config file (YAML format)"
)
parser.add_argument("--host", default="localhost", help="Hostname of the server")
parser.add_argument("-p", "--port", type=int, default=5000, help="Port of the server")
parser.add_argument(
"-vv",
"--debug",
action="store_true",
help="Enable debug mode. Starts a Flask debug server instead of Gunicorn.",
default=False,
)
args = parser.parse_args()
if args.debug:
app = create_app(config_path=os.path.abspath(args.config_file))
app.run(debug=args.debug, host=args.host, port=args.port)
else:
serve_via_gunicorn(config_file=args.config_file)
if __name__ == "__main__":
main()

86
home_stream/helpers.py Normal file
View File

@@ -0,0 +1,86 @@
# SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
#
# SPDX-License-Identifier: GPL-3.0-only
"""Helper functions for the media browser."""
import hashlib
import hmac
import os
import subprocess
import yaml
from bcrypt import checkpw
from flask import Flask, abort, current_app, request
def load_config(app: Flask, filename: str) -> None:
"""Load configuration from a YAML file."""
with open(filename, encoding="UTF-8") as f:
config = yaml.safe_load(f)
for key in ("users", "video_extensions", "audio_extensions", "media_root", "secret_key"):
if key not in config:
raise KeyError(f"Missing '{key}' key in config file.")
config["media_extensions"] = config.get("video_extensions", []) + config.get(
"audio_extensions", []
)
for key, value in config.items():
if key == "secret_key":
app.secret_key = value
app.config["STREAM_SECRET"] = value
else:
app.config[key.upper()] = value
def secure_path(subpath):
"""Secure the path to prevent directory traversal attacks."""
full_path = os.path.realpath(os.path.join(current_app.config["MEDIA_ROOT"], subpath))
if not full_path.startswith(os.path.realpath(current_app.config["MEDIA_ROOT"])):
abort(403)
return full_path
def file_type(filename):
"""Determine the file type based on its extension."""
ext = os.path.splitext(filename)[1].lower().strip(".")
return "audio" if ext in current_app.config["AUDIO_EXTENSIONS"] else "video"
def verify_password(username, password):
"""Verify the provided username and password."""
if username in current_app.config["USERS"] and checkpw(
password.encode("utf-8"), current_app.config["USERS"].get(username).encode("utf-8")
):
request.password = password
return username
return None
def validate_user(username, password):
"""Used for session-based auth (login form)."""
if username in current_app.config["USERS"]:
return checkpw(
password.encode("utf-8"), current_app.config["USERS"][username].encode("utf-8")
)
return False
def get_stream_token(username: str) -> str:
"""Generate a 16-chars permanent token for streaming based on username and secret key."""
secret = current_app.config["STREAM_SECRET"]
return hmac.new(secret.encode(), username.encode(), hashlib.sha256).hexdigest()[:16]
def serve_via_gunicorn(config_file: str) -> None:
"""Serve the application using Gunicorn."""
subprocess.run(
[
"gunicorn",
"-w",
"4",
"-b",
"0.0.0.0:8000",
f"home_stream.wsgi:create_app('{config_file}')",
],
check=True,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
SPDX-License-Identifier: CC0-1.0

View File

@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
//
// SPDX-License-Identifier: GPL-3.0-only
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
button.classList.add("secondary");
});
}

5
home_stream/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
*
* SPDX-License-Identifier: GPL-3.0-only
*/
:root {
--pico-form-element-spacing-vertical: 0.2rem;
--pico-form-element-spacing-horizontal: 0.2rem;
}
ul.files li {
padding: 0.2rem 0;
}
audio, video {
width: 800px;
}

View File

@@ -0,0 +1,28 @@
<!--
SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
SPDX-License-Identifier: GPL-3.0-only
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Home Stream</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='pico.min.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
<script src="{{ url_for('static', filename='main.js') }}" crossorigin></script>
</head>
<body>
<main class="container">
<h1>{{ path or 'Overview' }}</h1>
{% if path %}
{% set parent = path.rsplit('/', 1)[0] if '/' in path else '' %}
<p><a href="{{ url_for('browse', subpath=parent) }}">⬅ One level up</a></p>
{% endif %}
{% block content %}
{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!--
SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
SPDX-License-Identifier: GPL-3.0-only
-->
{% extends "base.html" %}
{% block content %}
{% if folders %}
<h2>Folders</h2>
<ul>
{% for name, rel in folders %}
<li><a href="{{ url_for('browse', subpath=rel) }}">{{ name }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if files %}
<h2>Media Files</h2>
<ul class="files">
{% for name, rel in files %}
<li>
{{ name }} —
<a href="{{ url_for('download', filepath=rel) }}" target="_blank"><button>💾 Download</button></a>
<a href="{{ url_for('play', filepath=rel) }}"><button>🎬 Play in browser</button></a>
<button onclick="copyToClipboard('{{ request.url_root }}dl-token/{{ username }}/{{ stream_token }}/{{ rel }}', this)">
▶️ Copy Stream URL
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,21 @@
<!--
SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
SPDX-License-Identifier: GPL-3.0-only
-->
{% extends "base.html" %}
{% block content %}
<h2>Login</h2>
{% if error %}
<p style="color: red">{{ error }}</p>
{% endif %}
<form method="post">
<label for="username">Username</label>
<input name="username" type="text" autofocus required>
<label for="password">Password</label>
<input name="password" type="password" required>
<button type="submit">Login</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,15 @@
<!--
SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
SPDX-License-Identifier: GPL-3.0-only
-->
{% extends "base.html" %}
{% block content %}
<h2>Player</h2>
<{{ mediatype }} controls autoplay preload>
<source src="{{ url_for('download', filepath=path) }}">
Your browser does not support the video tag.
</video>
{% endblock %}

12
home_stream/wsgi.py Normal file
View File

@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
#
# SPDX-License-Identifier: GPL-3.0-only
"""WSGI entry point for the Home Stream application."""
from home_stream.app import create_app as build_app
def create_app(config_path):
"""Create a Flask application instance."""
return build_app(config_path)