Files
yggdrasil/server/app.py
T

187 lines
6.2 KiB
Python

import os
import click
from flask import (
Flask,
jsonify,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user, login_required, login_user, logout_user
from auth import init_login_manager
from config import load_secret_key, sensors_api_url, users_database_uri
from sensors_client import SensorsAPIError, SensorsClient
from user_models import User, user_db
def create_app():
app = Flask(__name__)
app.config["SECRET_KEY"] = load_secret_key()
app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri()
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SENSORS_API_URL"] = sensors_api_url()
user_db.init_app(app)
sensors = SensorsClient(app.config["SENSORS_API_URL"])
def _login_redirect(req):
login_url = url_for("login")
if req.path and req.path != login_url:
return url_for("login", next=req.path)
return login_url
def _load_user(user_id):
try:
return user_db.session.get(User, int(user_id))
except (TypeError, ValueError):
return None
init_login_manager(app, _login_redirect, _load_user)
def _is_safe_next(target: str | None) -> bool:
return bool(target) and target.startswith("/") and not target.startswith("//")
def _proxy(call):
try:
status, data = call()
except SensorsAPIError as exc:
return jsonify({"error": str(exc)}), 502
return jsonify(data), status
@app.context_processor
def inject_service_urls():
return {"main_api_url": request.url_root.rstrip("/") + "/api"}
# --- Pages ---
@app.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("home"))
return redirect(url_for("login"))
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("home"))
error = None
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.check_password(password):
login_user(user)
next_page = request.args.get("next") or request.form.get("next")
if not _is_safe_next(next_page):
next_page = url_for("home")
return redirect(next_page)
error = "Invalid username or password."
return render_template("login.html", error=error, next=request.args.get("next", ""))
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
@app.route("/home")
@login_required
def home():
return render_template("home.html", user=current_user)
@app.route("/sensors")
@login_required
def sensors_page():
return render_template("sensors.html", user=current_user)
@app.route("/api/docs")
@login_required
def api_docs():
return render_template("docs.html", user=current_user)
# --- API: proxied to the sensors service ---
# The browser only talks to this app; the sensors service owns the
# sensor database and the actual API implementation.
@app.route("/api/sensors")
def api_sensors():
return _proxy(sensors.list_sensors)
@app.route("/api/sensors", methods=["POST"])
def api_create_sensor():
return _proxy(lambda: sensors.create_sensor(request.get_json(silent=True)))
@app.route("/api/sensors/<int:sensor_id>/readouts", methods=["POST"])
def api_create_readout(sensor_id):
return _proxy(
lambda: sensors.create_readout(sensor_id, request.get_json(silent=True))
)
@app.route("/api/sensors/<int:sensor_id>/types")
@login_required
def api_sensor_types(sensor_id):
return _proxy(lambda: sensors.sensor_types(sensor_id))
@app.route("/api/sensors/<int:sensor_id>/readouts")
@login_required
def api_sensor_readouts(sensor_id):
return _proxy(
lambda: sensors.sensor_readouts(
sensor_id, {"type": request.args.get("type")}
)
)
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
@login_required
def api_sensor_readouts_hourly(sensor_id):
return _proxy(
lambda: sensors.sensor_readouts_hourly(sensor_id, request.args.to_dict())
)
# --- Command for creating the first admin ---
# Usage:
# flask --app app create-admin --username admin --password 'secret'
# ADMIN_USERNAME=admin ADMIN_PASSWORD=secret flask --app app create-admin
@app.cli.command("create-admin")
@click.option("--username", default=None, help="Administrator username")
@click.option("--password", default=None, help="Administrator password")
def create_admin(username, password):
"""Create or update an administrator account (is_admin=True)."""
username = username or os.environ.get("ADMIN_USERNAME")
if not username:
username = click.prompt("Admin username")
username = username.strip()
if not username:
raise click.ClickException("Username cannot be empty.")
password = password or os.environ.get("ADMIN_PASSWORD")
if not password:
password = click.prompt("Admin password", hide_input=True, confirmation_prompt=True)
if not password:
raise click.ClickException("Password cannot be empty.")
user = User.query.filter_by(username=username).first()
if user:
user.set_password(password)
user.is_admin = True
user_db.session.commit()
click.echo(f"Updated existing user '{username}' (granted is_admin=True).")
else:
user = User(username=username, is_admin=True)
user.set_password(password)
user_db.session.add(user)
user_db.session.commit()
click.echo(f"Created administrator '{username}'.")
return app
if __name__ == "__main__":
app = create_app()
app.run(debug=True, host='0.0.0.0', port=8000)