Compare commits

..

7 Commits

3 changed files with 65 additions and 45 deletions

View File

@@ -58,7 +58,8 @@ Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaW
- `ORDER_TO` Empfänger, mehrere per Komma - `ORDER_TO` Empfänger, mehrere per Komma
**Optional** **Optional**
- `APP_API_KEY` APIKey für direkten Zugriff auf `/wawi/api/bestand` - `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order`
- `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http
## Deployment (systemd + Gunicorn) ## Deployment (systemd + Gunicorn)
1) App nach `/var/www/hellas/wawi` kopieren 1) App nach `/var/www/hellas/wawi` kopieren
@@ -146,6 +147,19 @@ sudo mkdir -p /var/www/hellas/wawi/static/uploads
sudo chown -R www-data:www-data /var/www/hellas/wawi/static/uploads sudo chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
``` ```
## FixPermissions Script (inkl. Uploads)
`/root/fix_wawi_permissions.sh` sollte auch das UploadVerzeichnis setzen:
```bash
#!/bin/sh
set -e
chown -R www-data:www-data /var/www/hellas/wawi
chmod 750 /var/www/hellas/wawi
chmod 640 /var/www/hellas/wawi/hellas.db
mkdir -p /var/www/hellas/wawi/static/uploads
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
systemctl restart hellas
```
## Backup (Beispiel) ## Backup (Beispiel)
```bash ```bash
sudo /root/fix_wawi_permissions.sh sudo /root/fix_wawi_permissions.sh

View File

@@ -101,23 +101,6 @@
} }
.pill input { accent-color: var(--accent); } .pill input { accent-color: var(--accent); }
main .wrap { padding-top: 14px; padding-bottom: 28px; } main .wrap { padding-top: 14px; padding-bottom: 28px; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.stat {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98));
display: grid;
gap: 4px;
box-shadow: var(--shadow);
}
.stat .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .8px; }
.stat .value { font-size: 18px; font-weight: 700; letter-spacing: .3px; color: var(--accent); }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.card-tile { .card-tile {
border: 1px solid rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.08);
@@ -212,8 +195,6 @@
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
thead th { background: rgba(0,0,0,.12); } thead th { background: rgba(0,0,0,.12); }
tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); } tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); }
.delta-pos { color: rgba(123,213,141,.98); font-weight: 700; }
.delta-neg { color: rgba(255,107,125,.98); font-weight: 700; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.small { font-size: 12px; } .small { font-size: 12px; }
.footer { .footer {
@@ -328,7 +309,6 @@
.brand { width: 100%; } .brand { width: 100%; }
.controls { width: 100%; } .controls { width: 100%; }
.search { min-width: 100%; } .search { min-width: 100%; }
.stats { grid-template-columns: 1fr; }
.card-media { aspect-ratio: 4 / 3; } .card-media { aspect-ratio: 4 / 3; }
.order-btn { width: 100%; justify-content: center; } .order-btn { width: 100%; justify-content: center; }
} }
@@ -358,7 +338,6 @@
<main> <main>
<div class="wrap"> <div class="wrap">
<div id="stats" class="stats" aria-live="polite"></div>
<div id="grid" class="grid" aria-live="polite"></div> <div id="grid" class="grid" aria-live="polite"></div>
<div class="footer"> <div class="footer">
<div>Tip: Auf einen Artikel klicken, um die GrößenTabelle zu öffnen.</div> <div>Tip: Auf einen Artikel klicken, um die GrößenTabelle zu öffnen.</div>
@@ -440,23 +419,8 @@ function badge(label, value, cls="") {
return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`; return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`;
} }
function deltaClass(d) {
if (d === null || d === undefined) return "";
if (d > 0) return "delta-pos";
if (d < 0) return "delta-neg";
return "";
}
function hasDiff(item) {
const t = item.totals || {};
return (t.abweichung ?? 0) !== 0 || (t.fehlbestand ?? 0) !== 0;
}
function render(items) { function render(items) {
const grid = document.getElementById("grid"); const grid = document.getElementById("grid");
const stats = document.getElementById("stats");
stats.innerHTML = "";
if (!items.length) { if (!items.length) {
grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`; grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`;
@@ -525,6 +489,7 @@ document.getElementById("q").addEventListener("input", applyFilters);
const modal = document.getElementById("orderModal"); const modal = document.getElementById("orderModal");
const form = document.getElementById("orderForm"); const form = document.getElementById("orderForm");
const setField = (id, v) => document.getElementById(id).value = v || ""; const setField = (id, v) => document.getElementById(id).value = v || "";
const ORDER_KEY = "";
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const imgBtn = e.target.closest(".thumb-btn"); const imgBtn = e.target.closest(".thumb-btn");
@@ -589,9 +554,12 @@ document.getElementById("detailModal").addEventListener("click", (e) => {
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const payload = Object.fromEntries(new FormData(form).entries()); const payload = Object.fromEntries(new FormData(form).entries());
const key = ORDER_KEY || "";
const headers = { "Content-Type": "application/json" };
if (key) headers["X-Order-Key"] = key;
const res = await fetch("/wawi/order", { const res = await fetch("/wawi/order", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers,
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (res.ok) { if (res.ok) {

View File

@@ -4,6 +4,8 @@ import os
import sqlite3 import sqlite3
import secrets import secrets
import smtplib import smtplib
import time
import threading
from uuid import uuid4 from uuid import uuid4
from email.message import EmailMessage from email.message import EmailMessage
from functools import wraps from functools import wraps
@@ -28,12 +30,18 @@ app = Flask(__name__, static_url_path=STATIC_URL_PATH)
# SessionSecret für LoginCookies (in Produktion unbedingt setzen). # SessionSecret für LoginCookies (in Produktion unbedingt setzen).
app.secret_key = os.environ.get("SECRET_KEY", "change-me") app.secret_key = os.environ.get("SECRET_KEY", "change-me")
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = False app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1"
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
bp = Blueprint("bp", __name__) bp = Blueprint("bp", __name__)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
_DB_INIT_DONE = False
_RATE_LIMIT = {}
_RATE_WINDOW = 60
_RATE_MAX = 15
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
if "db" not in g: if "db" not in g:
@@ -146,8 +154,11 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
@app.before_request @app.before_request
def ensure_db() -> None: def ensure_db() -> None:
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent). # Erstellt Tabellen einmal pro Worker.
init_db() global _DB_INIT_DONE
if not _DB_INIT_DONE:
init_db()
_DB_INIT_DONE = True
def now_iso() -> str: def now_iso() -> str:
@@ -160,6 +171,8 @@ def save_upload(file) -> str | None:
ext = os.path.splitext(file.filename)[1].lower() ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXT: if ext not in ALLOWED_EXT:
return None return None
if not (file.mimetype or "").startswith("image/"):
return None
name = secure_filename(Path(file.filename).stem) or "image" name = secure_filename(Path(file.filename).stem) or "image"
safe_name = f"{name}-{uuid4().hex}{ext}" safe_name = f"{name}-{uuid4().hex}{ext}"
dest = UPLOAD_DIR / safe_name dest = UPLOAD_DIR / safe_name
@@ -214,6 +227,16 @@ def api_key_required(fn):
return wrapper return wrapper
def rate_limited(ip: str) -> bool:
now = time.time()
bucket = _RATE_LIMIT.setdefault(ip, [])
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
if len(bucket) >= _RATE_MAX:
return True
bucket.append(now)
return False
@bp.route("/") @bp.route("/")
@login_required @login_required
def index(): def index():
@@ -534,10 +557,22 @@ def build_bestand() -> list[dict]:
@bp.route("/order", methods=["POST"]) @bp.route("/order", methods=["POST"])
def order(): def order():
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
if rate_limited(ip):
return jsonify({"error": "Zu viele Anfragen."}), 429
expected_key = os.environ.get("APP_API_KEY", "")
if expected_key:
provided = request.headers.get("X-Order-Key") or request.args.get("key") or ""
if provided != expected_key:
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json(silent=True) or request.form data = request.get_json(silent=True) or request.form
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"] required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
if any(not (data.get(k) or "").strip() for k in required): if any(not (data.get(k) or "").strip() for k in required):
return jsonify({"error": "Pflichtfelder fehlen."}), 400 return jsonify({"error": "Pflichtfelder fehlen."}), 400
if int(data.get("menge") or 0) <= 0:
return jsonify({"error": "Menge muss größer als 0 sein."}), 400
db = get_db() db = get_db()
db.execute( db.execute(
@@ -586,10 +621,13 @@ def order():
) )
msg.set_content(body) msg.set_content(body)
with smtplib.SMTP(smtp_host, smtp_port) as server: def _send():
server.starttls() with smtplib.SMTP(smtp_host, smtp_port) as server:
server.login(smtp_user, smtp_pass) server.starttls()
server.send_message(msg, to_addrs=recipients) server.login(smtp_user, smtp_pass)
server.send_message(msg, to_addrs=recipients)
threading.Thread(target=_send, daemon=True).start()
return jsonify({"ok": True}) return jsonify({"ok": True})