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
**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)
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
```
## 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)
```bash
sudo /root/fix_wawi_permissions.sh

View File

@@ -101,23 +101,6 @@
}
.pill input { accent-color: var(--accent); }
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)); }
.card-tile {
border: 1px solid rgba(255,255,255,.08);
@@ -212,8 +195,6 @@
tr:last-child td { border-bottom: 0; }
thead th { background: rgba(0,0,0,.12); }
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); }
.small { font-size: 12px; }
.footer {
@@ -328,7 +309,6 @@
.brand { width: 100%; }
.controls { width: 100%; }
.search { min-width: 100%; }
.stats { grid-template-columns: 1fr; }
.card-media { aspect-ratio: 4 / 3; }
.order-btn { width: 100%; justify-content: center; }
}
@@ -358,7 +338,6 @@
<main>
<div class="wrap">
<div id="stats" class="stats" aria-live="polite"></div>
<div id="grid" class="grid" aria-live="polite"></div>
<div class="footer">
<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>`;
}
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) {
const grid = document.getElementById("grid");
const stats = document.getElementById("stats");
stats.innerHTML = "";
if (!items.length) {
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 form = document.getElementById("orderForm");
const setField = (id, v) => document.getElementById(id).value = v || "";
const ORDER_KEY = "";
document.addEventListener("click", (e) => {
const imgBtn = e.target.closest(".thumb-btn");
@@ -589,9 +554,12 @@ document.getElementById("detailModal").addEventListener("click", (e) => {
form.addEventListener("submit", async (e) => {
e.preventDefault();
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify(payload)
});
if (res.ok) {

View File

@@ -4,6 +4,8 @@ import os
import sqlite3
import secrets
import smtplib
import time
import threading
from uuid import uuid4
from email.message import EmailMessage
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).
app.secret_key = os.environ.get("SECRET_KEY", "change-me")
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
bp = Blueprint("bp", __name__)
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:
if "db" not in g:
@@ -146,8 +154,11 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
@app.before_request
def ensure_db() -> None:
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent).
init_db()
# Erstellt Tabellen einmal pro Worker.
global _DB_INIT_DONE
if not _DB_INIT_DONE:
init_db()
_DB_INIT_DONE = True
def now_iso() -> str:
@@ -160,6 +171,8 @@ def save_upload(file) -> str | None:
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXT:
return None
if not (file.mimetype or "").startswith("image/"):
return None
name = secure_filename(Path(file.filename).stem) or "image"
safe_name = f"{name}-{uuid4().hex}{ext}"
dest = UPLOAD_DIR / safe_name
@@ -214,6 +227,16 @@ def api_key_required(fn):
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("/")
@login_required
def index():
@@ -534,10 +557,22 @@ def build_bestand() -> list[dict]:
@bp.route("/order", methods=["POST"])
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
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
if any(not (data.get(k) or "").strip() for k in required):
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.execute(
@@ -586,10 +621,13 @@ def order():
)
msg.set_content(body)
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg, to_addrs=recipients)
def _send():
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg, to_addrs=recipients)
threading.Thread(target=_send, daemon=True).start()
return jsonify({"ok": True})