Compare commits
7 Commits
f6495eb82a
...
db2767a496
| Author | SHA1 | Date | |
|---|---|---|---|
| db2767a496 | |||
| efffdfa3fa | |||
| 65ec9466eb | |||
| ec2d8945b3 | |||
| 513c126fba | |||
| a61e96e8b8 | |||
|
|
a7d058b57c |
16
README.md
16
README.md
@@ -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` – API‑Key für direkten Zugriff auf `/wawi/api/bestand`
|
||||
- `APP_API_KEY` – gemeinsamer API‑Key für `/wawi/api/bestand` **und** `/wawi/order`
|
||||
- `COOKIE_SECURE` – `1` (default) setzt Secure‑Cookie, `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
|
||||
```
|
||||
|
||||
## Fix‑Permissions Script (inkl. Uploads)
|
||||
`/root/fix_wawi_permissions.sh` sollte auch das Upload‑Verzeichnis 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
|
||||
|
||||
42
index.html
42
index.html
@@ -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ößen‑Tabelle 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) {
|
||||
|
||||
52
wawi/app.py
52
wawi/app.py
@@ -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)
|
||||
# Session‑Secret für Login‑Cookies (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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user