system użytkowników
This commit is contained in:
@@ -6,3 +6,4 @@ managed_components
|
|||||||
.cache
|
.cache
|
||||||
.vscode
|
.vscode
|
||||||
.clangd
|
.clangd
|
||||||
|
.secret_key
|
||||||
|
|||||||
@@ -46,6 +46,40 @@ alembic upgrade head
|
|||||||
python seed.py
|
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
|
## Running
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|||||||
@@ -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 ###
|
||||||
+117
-2
@@ -1,9 +1,19 @@
|
|||||||
import os
|
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 sqlalchemy import func
|
||||||
|
|
||||||
from models import Sensor, SensorReadout, db
|
from models import Sensor, SensorReadout, User, db
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
@@ -12,23 +22,87 @@ def create_app():
|
|||||||
db_path = os.path.join(os.path.dirname(__file__), "data.db")
|
db_path = os.path.join(os.path.dirname(__file__), "data.db")
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
|
||||||
|
|
||||||
db.init_app(app)
|
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")
|
@app.route("/sensors")
|
||||||
|
@login_required
|
||||||
def sensors_page():
|
def sensors_page():
|
||||||
return render_template("sensors.html")
|
return render_template("sensors.html")
|
||||||
|
|
||||||
@app.route("/api/docs")
|
@app.route("/api/docs")
|
||||||
|
@login_required
|
||||||
def api_docs():
|
def api_docs():
|
||||||
return render_template("docs.html")
|
return render_template("docs.html")
|
||||||
|
|
||||||
|
# --- API: odczyt (wymaga zalogowania, sesja z przeglądarki wystarcza) ---
|
||||||
|
|
||||||
@app.route("/api/sensors")
|
@app.route("/api/sensors")
|
||||||
|
@login_required
|
||||||
def api_sensors():
|
def api_sensors():
|
||||||
sensors = Sensor.query.order_by(Sensor.name).all()
|
sensors = Sensor.query.order_by(Sensor.name).all()
|
||||||
return jsonify([{"id": s.id, "name": s.name} for s in sensors])
|
return jsonify([{"id": s.id, "name": s.name} for s in sensors])
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/types")
|
@app.route("/api/sensors/<int:sensor_id>/types")
|
||||||
|
@login_required
|
||||||
def api_sensor_types(sensor_id):
|
def api_sensor_types(sensor_id):
|
||||||
types = (
|
types = (
|
||||||
db.session.query(SensorReadout.type)
|
db.session.query(SensorReadout.type)
|
||||||
@@ -40,6 +114,7 @@ def create_app():
|
|||||||
return jsonify([t[0] for t in types])
|
return jsonify([t[0] for t in types])
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/readouts")
|
@app.route("/api/sensors/<int:sensor_id>/readouts")
|
||||||
|
@login_required
|
||||||
def api_sensor_readouts(sensor_id):
|
def api_sensor_readouts(sensor_id):
|
||||||
type_ = request.args.get("type")
|
type_ = request.args.get("type")
|
||||||
query = SensorReadout.query.filter_by(sensor_id=sensor_id)
|
query = SensorReadout.query.filter_by(sensor_id=sensor_id)
|
||||||
@@ -54,6 +129,7 @@ def create_app():
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
|
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
|
||||||
|
@login_required
|
||||||
def api_sensor_readouts_hourly(sensor_id):
|
def api_sensor_readouts_hourly(sensor_id):
|
||||||
type_ = request.args.get("type")
|
type_ = request.args.get("type")
|
||||||
query = (
|
query = (
|
||||||
@@ -74,6 +150,8 @@ def create_app():
|
|||||||
[{"timestamp": r.hour, "value": round(r.avg_value, 2)} for r in rows]
|
[{"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"])
|
@app.route("/api/sensors", methods=["POST"])
|
||||||
def api_create_sensor():
|
def api_create_sensor():
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
@@ -128,6 +206,43 @@ def create_app():
|
|||||||
}
|
}
|
||||||
), 201
|
), 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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
db = SQLAlchemy()
|
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):
|
class Sensor(db.Model):
|
||||||
__tablename__ = "sensor"
|
__tablename__ = "sensor"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
flask>=3.0
|
flask>=3.0
|
||||||
flask-sqlalchemy>=3.1
|
flask-sqlalchemy>=3.1
|
||||||
|
flask-login>=0.6
|
||||||
alembic>=1.13
|
alembic>=1.13
|
||||||
gunicorn>=22.0
|
gunicorn>=22.0
|
||||||
Executable
+41
@@ -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()"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Strona domowa</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
nav a { margin-right: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Dom</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensory</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Wyloguj</a>
|
||||||
|
</nav>
|
||||||
|
<h1>Witaj, {{ user.username }}!</h1>
|
||||||
|
<p>Jesteś zalogowany{% if user.is_admin %} jako administrator{% endif %}.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Logowanie</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 400px; margin: 4rem auto; padding: 0 1rem; }
|
||||||
|
form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
label { font-weight: bold; }
|
||||||
|
input { padding: 0.5rem; font-size: 1rem; }
|
||||||
|
button { padding: 0.6rem; font-size: 1rem; cursor: pointer; }
|
||||||
|
.error { background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Logowanie</h1>
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('login', next=next) if next else url_for('login') }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<div>
|
||||||
|
<label for="username">Użytkownik:</label><br>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username" style="width: 100%; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Hasło:</label><br>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password" style="width: 100%; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Zaloguj</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Dom</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Wyloguj</a>
|
||||||
|
</nav>
|
||||||
<h1>Sensor Readouts</h1>
|
<h1>Sensor Readouts</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
ssh -R 5432:localhost:8000 vps
|
||||||
@@ -9,6 +9,6 @@ typedef struct {
|
|||||||
|
|
||||||
extern ygg_sensor_readout CURR_READOUT;
|
extern ygg_sensor_readout CURR_READOUT;
|
||||||
static const char *SENSOR_NAME = "BMP_280";
|
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
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user