diff --git a/.gitignore b/.gitignore index c1271bb..1c9de0c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ managed_components .cache .vscode .clangd +.secret_key diff --git a/server/README.md b/server/README.md index 9a2244a..176b223 100644 --- a/server/README.md +++ b/server/README.md @@ -46,6 +46,40 @@ alembic upgrade head python seed.py ``` +## Users + +There is no public registration. All accounts are created by an administrator. +Pages (`/`, `/home`, `/sensors`, `/api/docs`) and `GET /api/*` require login. +`POST /api/*` stays open so devices can push data without a browser session. + +Set a secret key for sessions (required in production): + +```bash +export SECRET_KEY='long-random-value' +``` + +### First admin + +The database starts with an empty `user` table. Create the first admin +with the CLI command (flags, env vars, or interactive prompts): + +```bash +# Run database migrations first +alembic upgrade head + +# Option 1: flags +flask --app app create-admin --username admin --password 'sekret' + +# Option 2: environment variables (e.g. on first deploy) +ADMIN_USERNAME=admin ADMIN_PASSWORD=sekret flask --app app create-admin + +# Option 3: interactive (asks for username/password) +flask --app app create-admin +``` + +Running `create-admin` for an existing username resets its password and +grants `is_admin=True`. After that log in at `/login` — you land on `/home`. + ## Running ### Development diff --git a/server/alembic/versions/c59e689377c2_add_user_table.py b/server/alembic/versions/c59e689377c2_add_user_table.py new file mode 100644 index 0000000..db94792 --- /dev/null +++ b/server/alembic/versions/c59e689377c2_add_user_table.py @@ -0,0 +1,40 @@ +"""add user table + +Revision ID: c59e689377c2 +Revises: 7f92bfc47dfc +Create Date: 2026-10-03 19:06:44.095699 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c59e689377c2' +down_revision: Union[str, Sequence[str], None] = '7f92bfc47dfc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user') + # ### end Alembic commands ### diff --git a/server/app.py b/server/app.py index 7f82c12..6fcd03f 100644 --- a/server/app.py +++ b/server/app.py @@ -1,9 +1,19 @@ import os -from flask import Flask, jsonify, render_template, request +import click +from flask import Flask, jsonify, redirect, render_template, request, url_for +from flask_login import ( + LoginManager, + current_user, + login_required, + login_user, + logout_user, +) from sqlalchemy import func -from models import Sensor, SensorReadout, db +from models import Sensor, SensorReadout, User, db + +login_manager = LoginManager() def create_app(): @@ -12,23 +22,87 @@ def create_app(): db_path = os.path.join(os.path.dirname(__file__), "data.db") app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key-change-me") db.init_app(app) + login_manager.init_app(app) + login_manager.login_view = "login" + + @login_manager.user_loader + def load_user(user_id): + return db.session.get(User, int(user_id)) + + @login_manager.unauthorized_handler + def unauthorized(): + # Dla API zwróć JSON zamiast przekierowania na stronę logowania. + if request.path.startswith("/api/"): + return jsonify({"error": "authentication required"}), 401 + next_page = request.path + login_url = url_for("login") + if next_page and next_page != login_url: + login_url += f"?next={next_page}" + return redirect(login_url) + + def _is_safe_next(target: str | None) -> bool: + return bool(target) and target.startswith("/") and not target.startswith("//") + + # --- Strony --- + + @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 = "Nieprawidłowa nazwa użytkownika lub hasło." + 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") @app.route("/api/docs") + @login_required def api_docs(): return render_template("docs.html") + # --- API: odczyt (wymaga zalogowania, sesja z przeglądarki wystarcza) --- + @app.route("/api/sensors") + @login_required def api_sensors(): sensors = Sensor.query.order_by(Sensor.name).all() return jsonify([{"id": s.id, "name": s.name} for s in sensors]) @app.route("/api/sensors//types") + @login_required def api_sensor_types(sensor_id): types = ( db.session.query(SensorReadout.type) @@ -40,6 +114,7 @@ def create_app(): return jsonify([t[0] for t in types]) @app.route("/api/sensors//readouts") + @login_required def api_sensor_readouts(sensor_id): type_ = request.args.get("type") query = SensorReadout.query.filter_by(sensor_id=sensor_id) @@ -54,6 +129,7 @@ def create_app(): ) @app.route("/api/sensors//readouts/hourly") + @login_required def api_sensor_readouts_hourly(sensor_id): type_ = request.args.get("type") query = ( @@ -74,6 +150,8 @@ def create_app(): [{"timestamp": r.hour, "value": round(r.avg_value, 2)} for r in rows] ) + # --- API: zapis (celowo bez logowania — wywołują je urządzenia/sensory) --- + @app.route("/api/sensors", methods=["POST"]) def api_create_sensor(): data = request.get_json(silent=True) @@ -128,6 +206,43 @@ def create_app(): } ), 201 + # --- Komenda do utworzenia pierwszego admina --- + # Użycie: + # flask --app app create-admin --username admin --password 'sekret' + # ADMIN_USERNAME=admin ADMIN_PASSWORD=sekret flask --app app create-admin + # flask --app app create-admin (zapyta interaktywnie) + + @app.cli.command("create-admin") + @click.option("--username", default=None, help="Nazwa administratora") + @click.option("--password", default=None, help="Hasło administratora") + def create_admin(username, password): + """Utwórz lub zaktualizuj konto administratora (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 + db.session.commit() + click.echo(f"Zaktualizowano istniejącego użytkownika '{username}' (nadano is_admin=True).") + else: + user = User(username=username, is_admin=True) + user.set_password(password) + db.session.add(user) + db.session.commit() + click.echo(f"Utworzono administratora '{username}'.") + return app diff --git a/server/models.py b/server/models.py index f54cb28..547dd91 100644 --- a/server/models.py +++ b/server/models.py @@ -1,10 +1,28 @@ from datetime import datetime, timezone +from flask_login import UserMixin from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import check_password_hash, generate_password_hash db = SQLAlchemy() +class User(UserMixin, db.Model): + __tablename__ = "user" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), nullable=False, unique=True) + password_hash = db.Column(db.String(255), nullable=False) + is_admin = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + class Sensor(db.Model): __tablename__ = "sensor" diff --git a/server/requirements.txt b/server/requirements.txt index 0854868..813c6e7 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,4 +1,5 @@ flask>=3.0 flask-sqlalchemy>=3.1 +flask-login>=0.6 alembic>=1.13 gunicorn>=22.0 \ No newline at end of file diff --git a/server/run-prod.sh b/server/run-prod.sh new file mode 100755 index 0000000..259162a --- /dev/null +++ b/server/run-prod.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Uruchomienie produkcyjne (gunicorn) ze stabilnym SECRET_KEY. +# +# Kolejność źródeł klucza: +# 1. zmienna środowiskowa SECRET_KEY (ma pierwszeństwo), +# 2. plik .secret_key obok skryptu (tworzony raz przy pierwszym starcie), +# 3. w przeciwnym razie generowany jest losowy klucz i zapisywany do .secret_key. +# +# Stabilny klucz ma znaczenie: podpisuje ciasteczka sesji, więc jego zmiana +# przy każdym restarcie wylogowałaby wszystkich użytkowników. +set -euo pipefail +cd "$(dirname "$0")" + +SECRET_FILE=".secret_key" + +if [ -z "${SECRET_KEY:-}" ]; then + if [ -f "$SECRET_FILE" ]; then + SECRET_KEY="$(cat "$SECRET_FILE")" + else + SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + printf '%s' "$SECRET_KEY" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "Wygenerowano nowy SECRET_KEY, zapisano w $SECRET_FILE" >&2 + fi + export SECRET_KEY +fi + +# Migracje przed startem, żeby schemat był zgodny z kodem. +if [ -x ".venv/bin/alembic" ]; then + .venv/bin/alembic upgrade head +elif command -v alembic >/dev/null 2>&1; then + alembic upgrade head +fi + +if [ -x ".venv/bin/gunicorn" ]; then + GUNICORN=".venv/bin/gunicorn" +else + GUNICORN="gunicorn" +fi + +exec "$GUNICORN" -c gunicorn.conf.py "app:create_app()" diff --git a/server/templates/home.html b/server/templates/home.html new file mode 100644 index 0000000..b69b231 --- /dev/null +++ b/server/templates/home.html @@ -0,0 +1,22 @@ + + + + + + Strona domowa + + + + +

Witaj, {{ user.username }}!

+

Jesteś zalogowany{% if user.is_admin %} jako administrator{% endif %}.

+ + diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..0567176 --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,34 @@ + + + + + + Logowanie + + + +

Logowanie

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ +
+
+ +
+
+
+ +
+ +
+ + diff --git a/server/templates/sensors.html b/server/templates/sensors.html index e2f640c..ed995cd 100644 --- a/server/templates/sensors.html +++ b/server/templates/sensors.html @@ -18,6 +18,10 @@ +

Sensor Readouts

diff --git a/server/vps_ssh_tunnel.sh b/server/vps_ssh_tunnel.sh new file mode 100755 index 0000000..99c4073 --- /dev/null +++ b/server/vps_ssh_tunnel.sh @@ -0,0 +1 @@ +ssh -R 5432:localhost:8000 vps diff --git a/thermo_sensor/main/ygg_state.h b/thermo_sensor/main/ygg_state.h index 7098bea..dc38ca0 100644 --- a/thermo_sensor/main/ygg_state.h +++ b/thermo_sensor/main/ygg_state.h @@ -9,6 +9,6 @@ typedef struct { extern ygg_sensor_readout CURR_READOUT; static const char *SENSOR_NAME = "BMP_280"; -static const char *BASE_URL = "http://192.168.0.236:5000"; +static const char *BASE_URL = "http://192.168.0.236:8000"; #endif