initial commit
This commit is contained in:
0
home_stream/__init__.py
Normal file
0
home_stream/__init__.py
Normal file
174
home_stream/app.py
Normal file
174
home_stream/app.py
Normal 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
86
home_stream/helpers.py
Normal 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,
|
||||
)
|
||||
BIN
home_stream/static/favicon.png
Normal file
BIN
home_stream/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
3
home_stream/static/favicon.png.license
Normal file
3
home_stream/static/favicon.png.license
Normal file
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
9
home_stream/static/main.js
Normal file
9
home_stream/static/main.js
Normal 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
5
home_stream/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
18
home_stream/static/style.css
Normal file
18
home_stream/static/style.css
Normal 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;
|
||||
}
|
||||
28
home_stream/templates/base.html
Normal file
28
home_stream/templates/base.html
Normal 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>
|
||||
33
home_stream/templates/browse.html
Normal file
33
home_stream/templates/browse.html
Normal 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 %}
|
||||
21
home_stream/templates/login.html
Normal file
21
home_stream/templates/login.html
Normal 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 %}
|
||||
15
home_stream/templates/play.html
Normal file
15
home_stream/templates/play.html
Normal 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
12
home_stream/wsgi.py
Normal 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)
|
||||
Reference in New Issue
Block a user