From f3d6e100eb7aa68b0a5cf5cf12bfc2521ebfb5ad Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 3 Jul 2026 23:12:43 +0200 Subject: [PATCH] chore(init): initialize repository for m0x.it Flask application - Implement lightweight link-shortener core using Flask and SQLite3 - Configure secure session cookie management via environment variables - Set up isolated database helper modules with strict contextual teardown - Add comprehensive .gitignore to prevent committing runtime environment and local databases - Define production-ready structure including template directories and dependencies --- .gitignore | 12 ++ app.py | 224 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + static/image/favicon.svg | 5 + templates/404.html | 28 +++++ templates/admin.html | 145 ++++++++++++++++++++++++ templates/admin_login.html | 79 +++++++++++++ templates/index.html | 37 ++++++ 8 files changed, 531 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/image/favicon.svg create mode 100644 templates/404.html create mode 100644 templates/admin.html create mode 100644 templates/admin_login.html create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7028617 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python-Umgebung ignorieren +.venv/ +venv/ +__pycache__/ +*.pyc + +# Lokale Datenbank & Ordner ignorieren +data/ +*.db + +# macOS Systemdateien ignorieren +.DS_Store diff --git a/app.py b/app.py new file mode 100644 index 0000000..5602be8 --- /dev/null +++ b/app.py @@ -0,0 +1,224 @@ +import os +import sqlite3 +import string +import secrets +from datetime import datetime, timezone +from functools import wraps + +from flask import ( + Flask, g, request, redirect, render_template, + session, url_for, abort, flash +) + +# -------------------------------------------------------------------------- +# Config +# -------------------------------------------------------------------------- +DB_PATH = os.environ.get("DB_PATH", "/data/links.db") +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") +SECRET_KEY = os.environ.get("SECRET_KEY") + +if not ADMIN_PASSWORD: + raise RuntimeError("ADMIN_PASSWORD env var ist nicht gesetzt") +if not SECRET_KEY: + raise RuntimeError("SECRET_KEY env var ist nicht gesetzt") + +BASE62 = string.digits + string.ascii_lowercase + string.ascii_uppercase +CODE_LENGTH = 6 + +app = Flask(__name__) +app.secret_key = SECRET_KEY +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "true").lower() == "true" + + +# -------------------------------------------------------------------------- +# DB Helpers +# -------------------------------------------------------------------------- +def get_db(): + if "db" not in g: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA foreign_keys = ON") + return g.db + + +@app.teardown_appcontext +def close_db(exception=None): + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS links ( + code TEXT PRIMARY KEY, + target TEXT NOT NULL, + is_alias INTEGER NOT NULL DEFAULT 0, + clicks INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + +# -------------------------------------------------------------------------- +# Code generation +# -------------------------------------------------------------------------- +def generate_code(length=CODE_LENGTH): + """Kryptographisch zufälligen, noch nicht vergebenen base62-Code erzeugen.""" + db = get_db() + for _ in range(20): + code = "".join(secrets.choice(BASE62) for _ in range(length)) + exists = db.execute("SELECT 1 FROM links WHERE code = ?", (code,)).fetchone() + if not exists: + return code + # extrem unwahrscheinlich, aber sicher ist sicher + raise RuntimeError("Konnte keinen freien Code generieren, Namespace evtl. zu voll") + + +def normalize_target(url: str) -> str: + url = url.strip() + if not url: + raise ValueError("Ziel-URL darf nicht leer sein") + if not (url.startswith("http://") or url.startswith("https://")): + url = "https://" + url + return url + + +# -------------------------------------------------------------------------- +# Auth +# -------------------------------------------------------------------------- +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("is_admin"): + return redirect(url_for("admin_login", next=request.path)) + return view(*args, **kwargs) + return wrapped + + +@app.route("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if request.method == "POST": + password = request.form.get("password", "") + # secrets.compare_digest schützt gegen Timing-Angriffe + if secrets.compare_digest(password, ADMIN_PASSWORD): + session.clear() + session["is_admin"] = True + next_url = request.args.get("next") or url_for("admin_dashboard") + return redirect(next_url) + flash("Falsches Passwort", "error") + return render_template("admin_login.html") + + +@app.route("/admin/logout") +def admin_logout(): + session.clear() + return redirect(url_for("admin_login")) + + +# -------------------------------------------------------------------------- +# Admin Dashboard +# -------------------------------------------------------------------------- +@app.route("/admin") +@login_required +def admin_dashboard(): + db = get_db() + links = db.execute( + "SELECT * FROM links ORDER BY created_at DESC" + ).fetchall() + return render_template("admin.html", links=links) + + +@app.route("/admin/create", methods=["POST"]) +@login_required +def admin_create(): + db = get_db() + target = request.form.get("target", "") + custom_code = request.form.get("code", "").strip() + + try: + target = normalize_target(target) + except ValueError as e: + flash(str(e), "error") + return redirect(url_for("admin_dashboard")) + + if custom_code: + # Alias-Regeln: nur URL-sichere Zeichen + allowed = set(string.ascii_letters + string.digits + "-_") + if not set(custom_code) <= allowed: + flash("Alias darf nur Buchstaben, Zahlen, - und _ enthalten", "error") + return redirect(url_for("admin_dashboard")) + existing = db.execute( + "SELECT 1 FROM links WHERE code = ?", (custom_code,) + ).fetchone() + if existing: + flash(f"Alias '{custom_code}' ist bereits vergeben", "error") + return redirect(url_for("admin_dashboard")) + code = custom_code + is_alias = 1 + else: + code = generate_code() + is_alias = 0 + + db.execute( + "INSERT INTO links (code, target, is_alias, clicks, created_at) VALUES (?, ?, ?, 0, ?)", + (code, target, is_alias, datetime.now(timezone.utc).isoformat()), + ) + db.commit() + flash(f"Erstellt: /{code} → {target}", "success") + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin/delete/", methods=["POST"]) +@login_required +def admin_delete(code): + db = get_db() + db.execute("DELETE FROM links WHERE code = ?", (code,)) + db.commit() + flash(f"Gelöscht: /{code}", "success") + return redirect(url_for("admin_dashboard")) + + +# -------------------------------------------------------------------------- +# Public Redirect +# -------------------------------------------------------------------------- +@app.route("/") +def index(): + return redirect(url_for("admin_login")) + + +@app.route("/") +def do_redirect(code): + db = get_db() + row = db.execute("SELECT * FROM links WHERE code = ?", (code,)).fetchone() + if row is None: + abort(404) + db.execute("UPDATE links SET clicks = clicks + 1 WHERE code = ?", (code,)) + db.commit() + return redirect(row["target"], code=302) + + +@app.errorhandler(404) +def not_found(e): + return render_template("404.html"), 404 + + +# -------------------------------------------------------------------------- +# Health check (nützlich für docker-compose healthcheck) +# -------------------------------------------------------------------------- +@app.route("/healthz") +def healthz(): + return {"status": "ok"}, 200 + + +init_db() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5001) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a6952dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.1 \ No newline at end of file diff --git a/static/image/favicon.svg b/static/image/favicon.svg new file mode 100644 index 0000000..d0fffc0 --- /dev/null +++ b/static/image/favicon.svg @@ -0,0 +1,5 @@ + + + m0x + + diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..ddf5e48 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,28 @@ + + + + + m0x.it // 404 + + + + + + +

404

+

Dieser Link existiert nicht (mehr).

+ + \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..d69ad61 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,145 @@ + + + + + m0x.it // Admin + + + + + + +
+

m0x.it // Links Logout

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ + + +
Alias leer lassen → zufälliger 6-stelliger base62-Code
+
+ + {% if links %} + + + + + + {% for link in links %} + + + + + + + + {% endfor %} + +
CodeZielKlicksErstellt
+ /{{ link.code }} + {% if link.is_alias %}alias{% else %}auto{% endif %} + {{ link.target }}{{ link.clicks }}{{ link.created_at[:10] }} +
+ +
+
+ {% else %} +
Noch keine Links vorhanden.
+ {% endif %} +
+ + \ No newline at end of file diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..592cdb5 --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,79 @@ + + + + + m0x.it // Admin Login + + + + + + +
+

m0x.it/admin

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} +
+ + +
+
+ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..22a477c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,37 @@ + + + + + + m0x.it + + + + + +
+

m0x.it

+

Die Webseite ist in Arbeit!

+
+ +