This commit is contained in:
2026-03-10 15:34:22 +00:00
parent 7635caa71d
commit d641e181ba
17 changed files with 347 additions and 7 deletions

View File

@@ -3,10 +3,42 @@ import os
from datetime import datetime, timedelta, timezone
from flask import Flask, redirect, render_template, request, url_for
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
from app import models
def _is_safe_redirect_url(url: str | None) -> bool:
"""Check that redirect URL is relative to our app (prevents open redirect)."""
if not url:
return False
return url.startswith("/") and "//" not in url
class User:
"""Flask-Login compatible user wrapper."""
def __init__(self, user_dict: dict):
self.id = user_dict["id"]
self.username = user_dict["username"]
self.is_admin = bool(user_dict.get("is_admin", 0))
def get_id(self) -> str:
return str(self.id)
@property
def is_authenticated(self) -> bool:
return True
@property
def is_active(self) -> bool:
return True
@property
def is_anonymous(self) -> bool:
return False
def _parse_report_dates(from_ts, to_ts, preset):
"""Parse from/to dates, applying preset if given. Returns (from_ts, to_ts, from_display, to_display)."""
now = datetime.now(timezone.utc)
@@ -36,22 +68,86 @@ app = Flask(
template_folder=os.path.join(ROOT, "templates"),
static_folder=os.path.join(ROOT, "static"),
)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
VERSION = os.environ.get("VERSION", "dev")
login_manager = LoginManager(app)
login_manager.login_view = "login"
login_manager.login_message = "Please log in to access this page."
@login_manager.user_loader
def load_user(user_id: str):
user_dict = models.get_user_by_id(int(user_id)) if user_id.isdigit() else None
return User(user_dict) if user_dict else None
@login_manager.unauthorized_handler
def unauthorized():
if request.is_json or request.accept_mimetypes.best == "application/json":
return {"error": "Authentication required"}, 401
return redirect(url_for("login", next=request.url))
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
user_dict = models.verify_user(username, password)
if user_dict:
login_user(User(user_dict))
next_param = request.form.get("next") or request.args.get("next")
next_url = next_param if _is_safe_redirect_url(next_param) else url_for("dashboard")
return redirect(next_url)
return render_template("login.html", error="Invalid username or password", version=VERSION)
return render_template("login.html", version=VERSION)
@app.route("/logout")
def logout():
logout_user()
return redirect(url_for("login"))
@app.route("/users", methods=["GET", "POST"])
@login_required
def users():
if not current_user.is_admin:
return "Forbidden", 403
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
user_id = models.create_user(username, password, is_admin=False)
if user_id:
return redirect(url_for("users"))
return render_template(
"users.html",
users=models.list_users(),
error="Username already exists or invalid",
version=VERSION,
)
return render_template("users.html", users=models.list_users(), version=VERSION)
@app.route("/")
@login_required
def dashboard():
services = models.list_services()
return render_template("dashboard.html", services=services, version=VERSION)
@app.route("/api/services", methods=["GET"])
@login_required
def api_list_services():
services = models.list_services()
return {"services": services}
@app.route("/api/services", methods=["POST"])
@login_required
def api_add_service():
data = request.get_json(silent=True)
if data is None and request.form:
@@ -77,6 +173,7 @@ def api_add_service():
@app.route("/api/services/<int:service_id>", methods=["DELETE"])
@login_required
def api_delete_service(service_id):
if models.delete_service(service_id):
return {"deleted": service_id}
@@ -84,6 +181,7 @@ def api_delete_service(service_id):
@app.route("/api/services/<int:service_id>", methods=["PATCH"])
@login_required
def api_update_service(service_id):
svc = models.get_service(service_id)
if not svc:
@@ -116,6 +214,7 @@ def api_update_service(service_id):
@app.route("/api/services/<int:service_id>")
@login_required
def api_get_service(service_id):
svc = models.get_service(service_id)
if not svc:
@@ -125,6 +224,7 @@ def api_get_service(service_id):
@app.route("/api/services/<int:service_id>/edit")
@login_required
def edit_service(service_id):
svc = models.get_service(service_id)
if not svc:
@@ -133,6 +233,7 @@ def edit_service(service_id):
@app.route("/api/services/<int:service_id>/report")
@login_required
def report(service_id):
svc = models.get_service(service_id)
if not svc:
@@ -191,6 +292,7 @@ def _format_period_label(from_display, to_display):
@app.route("/api/services/<int:service_id>/history")
@login_required
def api_history(service_id):
svc = models.get_service(service_id)
if not svc:
@@ -207,6 +309,7 @@ def api_history(service_id):
@app.route("/api/services/<int:service_id>/stats")
@login_required
def api_report_stats(service_id):
"""JSON report stats with optional from/to query params for date range."""
svc = models.get_service(service_id)